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