diff --git a/Gemfile b/Gemfile index 98e165f3ac9..8c54f882909 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 e859be90ef2..d7fcb79f8d5 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 00000000000..9481f7328e1 --- /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 00000000000..4de268433dc --- /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 f990437ce5c..572fd509f7f 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 00000000000..3161831869c --- /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 5a025ce7a6e..d062144afef 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({ } }) .catch(popupAjaxError); + }, + authenticateSecurityKey() { + getWebauthnCredential( + this.model.challenge, + this.model.allowed_credential_ids, + credentialData => { + this.set("securityKeyCredential", credentialData); + this.send("finishLogin"); + }, + errorMessage => { + this.set("model.error", errorMessage); + } + ); } } }); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index dfe7eaf68c7..5dfc9227da7 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -8,6 +8,7 @@ import { escapeExpression, areCookiesEnabled } from "discourse/lib/utilities"; import { extractError } from "discourse/lib/ajax-error"; import computed from "ember-addons/ember-computed-decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; +import { getWebauthnCredential } from "discourse/lib/webauthn"; // This is happening outside of the app via popup const AuthErrors = [ @@ -43,19 +44,20 @@ export default Ember.Controller.extend(ModalFunctionality, { loggedIn: false, secondFactorRequired: false, showSecondFactor: false, + showSecurityKey: false, showLoginButtons: true, awaitingApproval: false }); }, - @computed("showSecondFactor") - credentialsClass(showSecondFactor) { - return showSecondFactor ? "hidden" : ""; + @computed("showSecondFactor", "showSecurityKey") + credentialsClass(showSecondFactor, showSecurityKey) { + return showSecondFactor || showSecurityKey ? "hidden" : ""; }, - @computed("showSecondFactor") - secondFactorClass(showSecondFactor) { - return showSecondFactor ? "" : "hidden"; + @computed("showSecondFactor", "showSecurityKey") + secondFactorClass(showSecondFactor, showSecurityKey) { + return showSecondFactor || showSecurityKey ? "" : "hidden"; }, @computed("awaitingApproval", "hasAtLeastOneLoginButton") @@ -66,6 +68,11 @@ export default Ember.Controller.extend(ModalFunctionality, { return classes.join(" "); }, + @computed("showSecondFactor", "showSecurityKey") + disableLoginFields(showSecondFactor, showSecurityKey) { + return showSecondFactor || showSecurityKey; + }, + @computed("canLoginLocalWithEmail") hasAtLeastOneLoginButton(canLoginLocalWithEmail) { return findAll().length > 0 || canLoginLocalWithEmail; @@ -109,15 +116,20 @@ export default Ember.Controller.extend(ModalFunctionality, { login: this.loginName, password: this.loginPassword, second_factor_token: this.secondFactorToken, - second_factor_method: this.secondFactorMethod + second_factor_method: this.secondFactorMethod, + security_key_credential: this.securityKeyCredential } }).then( result => { // Successful login if (result && result.error) { this.set("loggingIn", false); + const invalidSecurityKey = result.reason === "invalid_security_key"; + const invalidSecondFactor = + result.reason === "invalid_second_factor"; + if ( - result.reason === "invalid_second_factor" && + (invalidSecondFactor || invalidSecurityKey) && !this.secondFactorRequired ) { document.getElementById("modal-alert").style.display = "none"; @@ -126,15 +138,24 @@ export default Ember.Controller.extend(ModalFunctionality, { secondFactorRequired: true, showLoginButtons: false, backupEnabled: result.backup_enabled, - showSecondFactor: true + showSecondFactor: invalidSecondFactor, + showSecurityKey: invalidSecurityKey, + secondFactorMethod: invalidSecurityKey + ? SECOND_FACTOR_METHODS.SECURITY_KEY + : SECOND_FACTOR_METHODS.TOTP, + securityKeyChallenge: result.challenge, + securityKeyAllowedCredentialIds: result.allowed_credential_ids }); - Ember.run.schedule("afterRender", () => - document - .getElementById("second-factor") - .querySelector("input") - .focus() - ); + // only need to focus the 2FA input for TOTP + if (!this.showSecurityKey) { + Ember.run.scheduleOnce("afterRender", () => + document + .getElementById("second-factor") + .querySelector("input") + .focus() + ); + } return; } else if (result.reason === "not_activated") { @@ -286,6 +307,20 @@ export default Ember.Controller.extend(ModalFunctionality, { }) .catch(e => this.flash(extractError(e), "error")) .finally(() => this.set("processingEmailLink", false)); + }, + + authenticateSecurityKey() { + getWebauthnCredential( + this.securityKeyChallenge, + this.securityKeyAllowedCredentialIds, + credentialData => { + this.set("securityKeyCredential", credentialData); + this.send("login"); + }, + errorMessage => { + this.flash(errorMessage, "error"); + } + ); } }, diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 2d48d74e2ec..ae3c5949a5f 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -4,13 +4,21 @@ import { ajax } from "discourse/lib/ajax"; import PasswordValidation from "discourse/mixins/password-validation"; import { userPath } from "discourse/lib/url"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; +import { getWebauthnCredential } from "discourse/lib/webauthn"; export default Ember.Controller.extend(PasswordValidation, { isDeveloper: Ember.computed.alias("model.is_developer"), admin: Ember.computed.alias("model.admin"), secondFactorRequired: Ember.computed.alias("model.second_factor_required"), + securityKeyRequired: Ember.computed.alias("model.security_key_required"), backupEnabled: Ember.computed.alias("model.backup_enabled"), - secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, + securityKeyOrSecondFactorRequired: Ember.computed.or( + "model.second_factor_required", + "model.security_key_required" + ), + secondFactorMethod: Ember.computed.alias("model.security_key_required") + ? SECOND_FACTOR_METHODS.SECURITY_KEY + : SECOND_FACTOR_METHODS.TOTP, passwordRequired: true, errorMessage: null, successMessage: null, @@ -39,7 +47,8 @@ export default Ember.Controller.extend(PasswordValidation, { data: { password: this.accountPassword, second_factor_token: this.secondFactorToken, - second_factor_method: this.secondFactorMethod + second_factor_method: this.secondFactorMethod, + security_key_credential: this.securityKeyCredential } }) .then(result => { @@ -53,15 +62,17 @@ export default Ember.Controller.extend(PasswordValidation, { DiscourseURL.redirectTo(result.redirect_to || "/"); } } else { - if (result.errors && result.errors.user_second_factors) { + if (result.errors && !result.errors.password) { this.setProperties({ - secondFactorRequired: true, + secondFactorRequired: this.secondFactorRequired, + securityKeyRequired: this.securityKeyRequired, password: null, errorMessage: result.message }); - } else if (this.secondFactorRequired) { + } else if (this.secondFactorRequired || this.securityKeyRequired) { this.setProperties({ secondFactorRequired: false, + securityKeyRequired: false, errorMessage: null }); } else if ( @@ -90,6 +101,24 @@ export default Ember.Controller.extend(PasswordValidation, { }); }, + authenticateSecurityKey() { + getWebauthnCredential( + this.model.challenge, + this.model.allowed_credential_ids, + credentialData => { + this.set("securityKeyCredential", credentialData); + this.send("submit"); + }, + errorMessage => { + this.setProperties({ + securityKeyRequired: true, + password: null, + errorMessage: errorMessage + }); + } + ); + }, + done() { this.set("redirected", true); DiscourseURL.redirectTo(this.redirectTo || "/"); 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 85129d453eb..34eb99d0e18 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -68,12 +68,14 @@ export default Ember.Controller.extend(CanCheckEmails, { errorMessage: null, loaded: true, totps: response.totps, + security_keys: response.security_keys, password: null, dirty: false }); this.set( "model.second_factor_enabled", - response.totps && response.totps.length > 0 + (response.totps && response.totps.length > 0) || + (response.security_keys && response.security_keys.length > 0) ); }) .catch(e => this.handleError(e)) @@ -147,6 +149,31 @@ export default Ember.Controller.extend(CanCheckEmails, { }); }, + createSecurityKey() { + const controller = showModal("second-factor-add-security-key", { + model: this.model, + title: "user.second_factor.security_key.add" + }); + controller.setProperties({ + onClose: () => this.loadSecondFactors(), + markDirty: () => this.markDirty(), + onError: e => this.handleError(e) + }); + }, + + editSecurityKey(security_key) { + const controller = showModal("second-factor-edit-security-key", { + model: security_key, + title: "user.second_factor.security_key.edit" + }); + controller.setProperties({ + user: this.model, + onClose: () => this.loadSecondFactors(), + markDirty: () => this.markDirty(), + onError: e => this.handleError(e) + }); + }, + editSecondFactor(second_factor) { const controller = showModal("second-factor-edit", { model: second_factor, diff --git a/app/assets/javascripts/discourse/controllers/second-factor-add-security-key.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-add-security-key.js.es6 new file mode 100644 index 00000000000..6106d2602b4 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/second-factor-add-security-key.js.es6 @@ -0,0 +1,123 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { bufferToBase64, stringToBuffer } from "discourse/lib/webauthn"; + +// model for this controller is user.js.es6 +export default Ember.Controller.extend(ModalFunctionality, { + loading: false, + errorMessage: null, + + onShow() { + // clear properties every time because the controller is a singleton + this.setProperties({ + errorMessage: null, + loading: true, + securityKeyName: I18n.t("user.second_factor.security_key.default_name") + }); + + this.model + .requestSecurityKeyChallenge() + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + + this.setProperties({ + errorMessage: null, + loading: false, + challenge: response.challenge, + relayingParty: { + id: response.rp_id, + name: response.rp_name + }, + supported_algoriths: response.supported_algoriths, + user_secure_id: response.user_secure_id, + existing_active_credential_ids: + response.existing_active_credential_ids + }); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) + .finally(() => this.set("loading", false)); + }, + + actions: { + registerSecurityKey() { + const publicKeyCredentialCreationOptions = { + challenge: Uint8Array.from(this.challenge, c => c.charCodeAt(0)), + rp: { + name: this.relayingParty.name, + id: this.relayingParty.id + }, + user: { + id: Uint8Array.from(this.user_secure_id, c => c.charCodeAt(0)), + displayName: this.model.username_lower, + name: this.model.username_lower + }, + pubKeyCredParams: this.supported_algoriths.map(alg => { + return { type: "public-key", alg: alg }; + }), + excludeCredentials: this.existing_active_credential_ids.map( + credentialId => { + return { + type: "public-key", + id: stringToBuffer(atob(credentialId)) + }; + } + ), + timeout: 20000, + attestation: "none", + authenticatorSelection: { + // see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why + // default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support + // user verification, which usually requires entering a PIN + userVerification: "discouraged" + } + }; + + navigator.credentials + .create({ + publicKey: publicKeyCredentialCreationOptions + }) + .then( + credential => { + let serverData = { + id: credential.id, + rawId: bufferToBase64(credential.rawId), + type: credential.type, + attestation: bufferToBase64( + credential.response.attestationObject + ), + clientData: bufferToBase64(credential.response.clientDataJSON), + name: this.securityKeyName + }; + + this.model + .registerSecurityKey(serverData) + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + this.markDirty(); + this.set("errorMessage", null); + this.send("closeModal"); + }) + .catch(error => this.onError(error)) + .finally(() => this.set("loading", false)); + }, + err => { + if (err.name === "NotAllowedError") { + return this.set( + "errorMessage", + I18n.t("user.second_factor.security_key.not_allowed_error") + ); + } + this.set("errorMessage", err.message); + } + ); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/second-factor-edit-security-key.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-edit-security-key.js.es6 new file mode 100644 index 00000000000..90815cfbea8 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/second-factor-edit-security-key.js.es6 @@ -0,0 +1,42 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Ember.Controller.extend(ModalFunctionality, { + actions: { + disableSecurityKey() { + this.user + .updateSecurityKey(this.model.id, this.model.name, true) + .then(response => { + if (response.error) { + return; + } + this.markDirty(); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) + .finally(() => { + this.set("loading", false); + this.send("closeModal"); + }); + }, + + editSecurityKey() { + this.user + .updateSecurityKey(this.model.id, this.model.name, false) + .then(response => { + if (response.error) { + return; + } + this.markDirty(); + }) + .catch(error => { + this.onError(error); + }) + .finally(() => { + this.set("loading", false); + this.send("closeModal"); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/lib/webauthn.js.es6 b/app/assets/javascripts/discourse/lib/webauthn.js.es6 new file mode 100644 index 00000000000..3ec74ba2f17 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/webauthn.js.es6 @@ -0,0 +1,74 @@ +export function stringToBuffer(str) { + let buffer = new ArrayBuffer(str.length); + let byteView = new Uint8Array(buffer); + for (let i = 0; i < str.length; i++) { + byteView[i] = str.charCodeAt(i); + } + return buffer; +} + +export function bufferToBase64(buffer) { + return btoa(String.fromCharCode(...new Uint8Array(buffer))); +} + +export function getWebauthnCredential( + challenge, + allowedCredentialIds, + successCallback, + errorCallback +) { + if (typeof PublicKeyCredential === "undefined") { + return errorCallback(I18n.t("login.security_key_support_missing_error")); + } + + let challengeBuffer = stringToBuffer(challenge); + let allowCredentials = allowedCredentialIds.map(credentialId => { + return { + id: stringToBuffer(atob(credentialId)), + type: "public-key" + }; + }); + + navigator.credentials + .get({ + publicKey: { + challenge: challengeBuffer, + allowCredentials: allowCredentials, + timeout: 60000, + + // see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why + // default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support + // user verification, which usually requires entering a PIN + userVerification: "discouraged" + } + }) + .then(credential => { + // 1. if there is a credential, check if the raw ID base64 matches + // any of the allowed credential ids + if ( + !allowedCredentialIds.some( + credentialId => bufferToBase64(credential.rawId) === credentialId + ) + ) { + return errorCallback( + I18n.t("login.security_key_no_matching_credential_error") + ); + } + + const credentialData = { + signature: bufferToBase64(credential.response.signature), + clientData: bufferToBase64(credential.response.clientDataJSON), + authenticatorData: bufferToBase64( + credential.response.authenticatorData + ), + credentialId: bufferToBase64(credential.rawId) + }; + successCallback(credentialData); + }) + .catch(err => { + if (err.name === "NotAllowedError") { + return errorCallback(I18n.t("login.security_key_not_allowed_error")); + } + errorCallback(err); + }); +} diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 84ac5efed42..a16b24f80ea 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -21,7 +21,11 @@ import { defaultHomepage } from "discourse/lib/utilities"; import { userPath } from "discourse/lib/url"; import Category from "discourse/models/category"; -export const SECOND_FACTOR_METHODS = { TOTP: 1, BACKUP_CODE: 2 }; +export const SECOND_FACTOR_METHODS = { + TOTP: 1, + BACKUP_CODE: 2, + SECURITY_KEY: 3 +}; const isForever = dt => moment().diff(dt, "years") < -500; @@ -375,6 +379,19 @@ const User = RestModel.extend({ }); }, + requestSecurityKeyChallenge() { + return ajax("/u/create_second_factor_security_key.json", { + type: "POST" + }); + }, + + registerSecurityKey(credential) { + return ajax("/u/register_second_factor_security_key.json", { + data: credential, + type: "POST" + }); + }, + createSecondFactorTotp() { return ajax("/u/create_second_factor_totp.json", { type: "POST" @@ -409,6 +426,17 @@ const User = RestModel.extend({ }); }, + updateSecurityKey(id, name, disable) { + return ajax("/u/security_key.json", { + data: { + name, + disable, + id + }, + type: "PUT" + }); + }, + toggleSecondFactor(authToken, authMethod, targetMethod, enable) { return ajax("/u/second_factor.json", { data: { diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 index f3a7c2ea7bd..763460bc239 100644 --- a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 @@ -14,6 +14,7 @@ export default RestrictedUserRoute.extend({ setupController(controller, model) { controller.setProperties({ model, newUsername: model.get("username") }); controller.set("loading", true); + model .loadSecondFactorCodes("") .then(response => { @@ -24,7 +25,8 @@ export default RestrictedUserRoute.extend({ errorMessage: null, loaded: !response.password_required, dirty: !!response.password_required, - totps: response.totps + totps: response.totps, + security_keys: response.security_keys }); } }) diff --git a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs index f2ccf505dfe..4c5c717be35 100644 --- a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs +++ b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs @@ -5,12 +5,9 @@ {{/if}}
{{secondFactorDescription}}
{{yield}} - {{#if backupEnabled}} + {{#if showToggleMethodLink}}- {{discourse-linked-text - class="toggle-second-factor-method" - action=(action "toggleSecondFactorMethod") - text=linkText}} + {{ i18n linkText }}
{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/security-key-form.hbs b/app/assets/javascripts/discourse/templates/components/security-key-form.hbs new file mode 100644 index 00000000000..e5bc247ad3c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/security-key-form.hbs @@ -0,0 +1,14 @@ ++ {{#if otherMethodAllowed}} + {{ i18n 'login.security_key_alternative' }} + {{/if}} +
+{{i18n "email_login.logging_in_as" email=model.token_email}}
{{/if}} - {{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}} + {{#unless model.security_key_required }} + {{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}} + {{/unless}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/modal/login.hbs b/app/assets/javascripts/discourse/templates/modal/login.hbs index 326fdd4999e..ff3b7f40eca 100644 --- a/app/assets/javascripts/discourse/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/modal/login.hbs @@ -8,11 +8,11 @@- | {{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus" disabled=showSecondFactor}} | +{{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus" disabled=disableLoginFields}} | |
- | {{password-field value=loginPassword type="password" id="login-account-password" maxlength="200" capsLockOn=capsLockOn disabled=showSecondFactor}} | +{{password-field value=loginPassword type="password" id="login-account-password" maxlength="200" capsLockOn=capsLockOn disabled=disableLoginFields}} | {{i18n 'forgot_password.action'}} |