FEATURE: Webauthn authenticator management with 2FA login (Security Keys) (#8099)

FEATURE: Webauthn authenticator management with 2FA login (Security Keys) (#8099)

Adds 2 factor authentication method via second factor security keys over web authn.

Allows a user to authenticate a second factor on login, login-via-email, admin-login, and change password routes. Adds registration area within existing user second factor preferences to register multiple security keys. Supports both external (yubikey) and built-in (macOS/android fingerprint readers).

diff --git a/Gemfile b/Gemfile
index 98e165f..8c54f88 100644
--- a/Gemfile
+++ b/Gemfile
@@ -114,6 +114,8 @@ gem 'execjs', require: false
 gem 'mini_racer'
 gem 'highline', '~> 1.7.0', require: false
 gem 'rack-protection' # security
+gem 'cbor', require: false
+gem 'cose', require: false
 
 # Gems used only for assets and not required in production environments by default.
 # Allow everywhere for now cause we are allowing asset debugging in production
diff --git a/Gemfile.lock b/Gemfile.lock
index e859be9..d7fcb79 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,12 +77,15 @@ GEM
       activesupport (>= 3.0.0)
       uniform_notifier (~> 1.11)
     byebug (11.0.1)
+    cbor (0.5.9.6)
     certified (1.0.0)
     chunky_png (1.3.11)
     coderay (1.1.2)
     colored2 (3.1.2)
     concurrent-ruby (1.1.5)
     connection_pool (2.2.2)
+    cose (0.9.0)
+      cbor (~> 0.5.9)
     cppjieba_rb (0.3.3)
     crack (0.4.3)
       safe_yaml (~> 1.0.0)
@@ -438,8 +441,10 @@ DEPENDENCIES
   bootsnap
   bullet
   byebug
+  cbor
   certified
   colored2
+  cose
   cppjieba_rb
   css_parser
   diffy
diff --git a/app/assets/javascripts/admin-login/admin-login.js.es6 b/app/assets/javascripts/admin-login/admin-login.js.es6
new file mode 100644
index 0000000..9481f73
--- /dev/null
+++ b/app/assets/javascripts/admin-login/admin-login.js.es6
@@ -0,0 +1,46 @@
+import { getWebauthnCredential } from "discourse/lib/webauthn";
+
+export default function() {
+  document.getElementById(
+    "activate-security-key-alternative"
+  ).onclick = function() {
+    document.getElementById("second-factor-forms").style.display = "block";
+    document.getElementById("primary-security-key-form").style.display = "none";
+  };
+
+  document.getElementById("submit-security-key").onclick = function(e) {
+    e.preventDefault();
+    getWebauthnCredential(
+      document.getElementById("security-key-challenge").value,
+      document
+        .getElementById("security-key-allowed-credential-ids")
+        .value.split(","),
+      credentialData => {
+        document.getElementById(
+          "security-key-credential"
+        ).value = JSON.stringify(credentialData);
+        e.target.parentElement.submit();
+      },
+      errorMessage => {
+        document.getElementById("security-key-error").innerText = errorMessage;
+      }
+    );
+  };
+
+  var useTotp = I18n.t("login.second_factor_toggle.totp");
+  var useBackup = I18n.t("login.second_factor_toggle.backup_code");
+  var backupForm = document.getElementById("backup-second-factor-form");
+  var primaryForm = document.getElementById("primary-second-factor-form");
+  document.getElementById("toggle-form").onclick = function(event) {
+    event.preventDefault();
+    if (backupForm.style.display === "none") {
+      backupForm.style.display = "block";
+      primaryForm.style.display = "none";
+      document.getElementById("toggle-form").innerHTML = useTotp;
+    } else {
+      backupForm.style.display = "none";
+      primaryForm.style.display = "block";
+      document.getElementById("toggle-form").innerHTML = useBackup;
+    }
+  };
+}
diff --git a/app/assets/javascripts/admin-login/admin-login.no-module.js.es6 b/app/assets/javascripts/admin-login/admin-login.no-module.js.es6
new file mode 100644
index 0000000..4de2684
--- /dev/null
+++ b/app/assets/javascripts/admin-login/admin-login.no-module.js.es6
@@ -0,0 +1 @@
+require("admin-login/admin-login").default();
diff --git a/app/assets/javascripts/discourse/components/second-factor-form.js.es6 b/app/assets/javascripts/discourse/components/second-factor-form.js.es6
index f990437..572fd50 100644
--- a/app/assets/javascripts/discourse/components/second-factor-form.js.es6
+++ b/app/assets/javascripts/discourse/components/second-factor-form.js.es6
@@ -4,16 +4,26 @@ import { SECOND_FACTOR_METHODS } from "discourse/models/user";
 export default Ember.Component.extend({
   @computed("secondFactorMethod")
   secondFactorTitle(secondFactorMethod) {
-    return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
-      ? I18n.t("login.second_factor_title")
-      : I18n.t("login.second_factor_backup_title");
+    switch (secondFactorMethod) {
+      case SECOND_FACTOR_METHODS.TOTP:
+        return I18n.t("login.second_factor_title");
+      case SECOND_FACTOR_METHODS.SECURITY_KEY:
+        return I18n.t("login.second_factor_title");
+      case SECOND_FACTOR_METHODS.BACKUP_CODE:
+        return I18n.t("login.second_factor_backup_title");
+    }
   },
 
   @computed("secondFactorMethod")
   secondFactorDescription(secondFactorMethod) {
-    return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
-      ? I18n.t("login.second_factor_description")
-      : I18n.t("login.second_factor_backup_description");
+    switch (secondFactorMethod) {
+      case SECOND_FACTOR_METHODS.TOTP:
+        return I18n.t("login.second_factor_description");
+      case SECOND_FACTOR_METHODS.SECURITY_KEY:
+        return I18n.t("login.security_key_description");
+      case SECOND_FACTOR_METHODS.BACKUP_CODE:
+        return I18n.t("login.second_factor_backup_description");
+    }
   },
 
   @computed("secondFactorMethod", "isLogin")
@@ -29,6 +39,13 @@ export default Ember.Component.extend({
     }
   },
 
+  @computed("backupEnabled", "secondFactorMethod")
+  showToggleMethodLink(backupEnabled, secondFactorMethod) {
+    return (
+      backupEnabled && secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY
+    );
+  },
+
   actions: {
     toggleSecondFactorMethod() {
       const secondFactorMethod = this.secondFactorMethod;
diff --git a/app/assets/javascripts/discourse/components/security-key-form.js.es6 b/app/assets/javascripts/discourse/components/security-key-form.js.es6
new file mode 100644
index 0000000..3161831
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/security-key-form.js.es6
@@ -0,0 +1,11 @@
+import { SECOND_FACTOR_METHODS } from "discourse/models/user";
+
+export default Ember.Component.extend({
+  actions: {
+    useAnotherMethod() {
+      this.set("showSecurityKey", false);
+      this.set("showSecondFactor", true);
+      this.set("secondFactorMethod", SECOND_FACTOR_METHODS.TOTP);
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/controllers/email-login.js.es6 b/app/assets/javascripts/discourse/controllers/email-login.js.es6
index 5a025ce..d062144 100644
--- a/app/assets/javascripts/discourse/controllers/email-login.js.es6
+++ b/app/assets/javascripts/discourse/controllers/email-login.js.es6
@@ -1,20 +1,40 @@
+import computed from "ember-addons/ember-computed-decorators";
 import { SECOND_FACTOR_METHODS } from "discourse/models/user";
 import { ajax } from "discourse/lib/ajax";
 import DiscourseURL from "discourse/lib/url";
 import { popupAjaxError } from "discourse/lib/ajax-error";
+import { getWebauthnCredential } from "discourse/lib/webauthn";
 
 export default Ember.Controller.extend({
-  secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
   lockImageUrl: Discourse.getURL("/images/lock.svg"),
+
+  @computed("model")
+  secondFactorRequired(model) {
+    return model.security_key_required || model.second_factor_required;
+  },
+
+  @computed("model")
+  secondFactorMethod(model) {
+    return model.security_key_required
+      ? SECOND_FACTOR_METHODS.SECURITY_KEY
+      : SECOND_FACTOR_METHODS.TOTP;
+  },
+
   actions: {
     finishLogin() {
+      let data = {};
+      if (this.securityKeyCredential) {
+        data = { security_key_credential: this.securityKeyCredential };
+      } else {
+        data = {
+          second_factor_token: this.secondFactorToken,
+          second_factor_method: this.secondFactorMethod
+        };
+      }
       ajax({
         url: `/session/email-login/${this.model.token}`,
         type: "POST",
-        data: {
-          second_factor_token: this.secondFactorToken,
-          second_factor_method: this.secondFactorMethod
-        }
+        data: data
       })
         .then(result => {
           if (result.success) {
@@ -24,6 +44,19 @@ export default Ember.Controller.extend({

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

GitHub sha: 68d35b14

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

We should not use var in javascript. @featheredtoast can you follow this up?

2 Likes

FIX: Confirm new email with backup codes enabled