diff --git a/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.hbs b/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.hbs new file mode 100644 index 00000000000..bde62e2e656 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.hbs @@ -0,0 +1,47 @@ + + <:body> + + {{#if this.errorMessage}} +
+
+
{{this.errorMessage}}
+
+
+ {{/if}} + +
+
+ {{html-safe + (i18n "user.second_factor.enable_security_key_description") + }} +
+
+ +
+
+ +
+
+ +
+
+ {{#unless this.webauthnUnsupported}} + + {{/unless}} +
+
+
+ +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.js b/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.js new file mode 100644 index 00000000000..624330fd73e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/second-factor-add-security-key.js @@ -0,0 +1,151 @@ +import { + bufferToBase64, + isWebauthnSupported, + stringToBuffer, +} from "discourse/lib/webauthn"; +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; + +export default class SecondFactorAddSecurityKey extends Component { + @service capabilities; + + @tracked loading = false; + @tracked errorMessage = null; + @tracked securityKeyName; + + get webauthnUnsupported() { + return !isWebauthnSupported(); + } + + @action + securityKeyRequested() { + let key; + if (this.capabilities.isIOS && !this.capabilities.isIpadOS) { + key = "user.second_factor.security_key.iphone_default_name"; + } else if (this.capabilities.isAndroid) { + key = "user.second_factor.security_key.android_default_name"; + } else { + key = "user.second_factor.security_key.default_name"; + } + this.securityKeyName = key; + + this.loading = true; + this.args.model.secondFactor + .requestSecurityKeyChallenge() + .then((response) => { + if (response.error) { + this.errorMessage = response.error; + return; + } + + this.errorMessage = isWebauthnSupported() + ? null + : I18n.t("login.security_key_support_missing_error"); + this.loading = false; + this.challenge = response.challenge; + this.relayingParty = { + id: response.rp_id, + name: response.rp_name, + }; + this.supported_algorithms = response.supported_algorithms; + this.user_secure_id = response.user_secure_id; + this.existing_active_credential_ids = + response.existing_active_credential_ids; + }) + .catch((error) => { + this.args.closeModal(); + this.args.model.onError(error); + }) + .finally(() => (this.loading = false)); + } + + @action + registerSecurityKey() { + if (!this.securityKeyName) { + this.errorMessage = I18n.t( + "user.second_factor.security_key.name_required_error" + ); + return; + } + 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.args.model.secondFactor.username_lower, + name: this.args.model.secondFactor.username_lower, + }, + pubKeyCredParams: this.supported_algorithms.map((alg) => { + return { type: "public-key", 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 necessarily 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.args.model.secondFactor + .registerSecurityKey(serverData) + .then((response) => { + if (response.error) { + this.errorMessage = response.error; + return; + } + this.args.model.markDirty(); + this.errorMessage = null; + this.args.closeModal(); + }) + .catch((error) => this.args.model.onError(error)) + .finally(() => (this.loading = false)); + }, + (err) => { + if (err.name === "InvalidStateError") { + this.errorMessage = I18n.t( + "user.second_factor.security_key.already_added_error" + ); + return; + } + if (err.name === "NotAllowedError") { + this.errorMessage = I18n.t( + "user.second_factor.security_key.not_allowed_error" + ); + return; + } + this.errorMessage = err.message; + } + ); + } +} diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/second-factor.js b/app/assets/javascripts/discourse/app/controllers/preferences/second-factor.js index a63ab61d520..e6907e57941 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/second-factor.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/second-factor.js @@ -11,9 +11,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import showModal from "discourse/lib/show-modal"; import { inject as service } from "@ember/service"; import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase"; +import SecondFactorAddSecurityKey from "discourse/components/modal/second-factor-add-security-key"; export default Controller.extend(CanCheckEmails, { dialog: service(), + modal: service(), loading: false, dirty: false, resetPasswordLoading: false, @@ -42,6 +44,7 @@ export default Controller.extend(CanCheckEmails, { return user && user.enforcedSecondFactor; }, + @action handleError(error) { if (error.jqXHR) { error = error.jqXHR; @@ -57,6 +60,7 @@ export default Controller.extend(CanCheckEmails, { } }, + @action loadSecondFactors() { if (this.dirty === false) { return; @@ -89,6 +93,7 @@ export default Controller.extend(CanCheckEmails, { .finally(() => this.set("loading", false)); }, + @action markDirty() { this.set("dirty", true); }, @@ -268,16 +273,15 @@ export default 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), + async createSecurityKey() { + await this.modal.show(SecondFactorAddSecurityKey, { + model: { + secondFactor: this.model, + markDirty: this.markDirty, + onError: this.handleError, + }, }); + this.loadSecondFactors(); }, editSecurityKey(security_key) { diff --git a/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js b/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js deleted file mode 100644 index 9bda6f81c14..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js +++ /dev/null @@ -1,157 +0,0 @@ -import { - bufferToBase64, - isWebauthnSupported, - stringToBuffer, -} from "discourse/lib/webauthn"; -import Controller from "@ember/controller"; -import I18n from "I18n"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; - -// model for this controller is user -export default Controller.extend(ModalFunctionality, { - loading: false, - errorMessage: null, - - onShow() { - let securityKeyName; - if (this.capabilities.isIOS && !this.capabilities.isIpadOS) { - securityKeyName = I18n.t( - "user.second_factor.security_key.iphone_default_name" - ); - } else if (this.capabilities.isAndroid) { - securityKeyName = I18n.t( - "user.second_factor.security_key.android_default_name" - ); - } else { - securityKeyName = I18n.t("user.second_factor.security_key.default_name"); - } - // clear properties every time because the controller is a singleton - this.setProperties({ - errorMessage: null, - loading: true, - securityKeyName, - webauthnUnsupported: !isWebauthnSupported(), - }); - - this.model - .requestSecurityKeyChallenge() - .then((response) => { - if (response.error) { - this.set("errorMessage", response.error); - return; - } - - this.setProperties({ - errorMessage: isWebauthnSupported() - ? null - : I18n.t("login.security_key_support_missing_error"), - loading: false, - challenge: response.challenge, - relayingParty: { - id: response.rp_id, - name: response.rp_name, - }, - supported_algorithms: response.supported_algorithms, - 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() { - if (!this.securityKeyName) { - this.set( - "errorMessage", - I18n.t("user.second_factor.security_key.name_required_error") - ); - return; - } - 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_algorithms.map((alg) => { - return { type: "public-key", 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 necessarily 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 === "InvalidStateError") { - return this.set( - "errorMessage", - I18n.t("user.second_factor.security_key.already_added_error") - ); - } - 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/app/templates/modal/second-factor-add-security-key.hbs b/app/assets/javascripts/discourse/app/templates/modal/second-factor-add-security-key.hbs deleted file mode 100644 index ada4360b481..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/second-factor-add-security-key.hbs +++ /dev/null @@ -1,41 +0,0 @@ - - - {{#if this.errorMessage}} -
-
-
{{this.errorMessage}}
-
-
- {{/if}} - -
-
- {{html-safe - (i18n "user.second_factor.enable_security_key_description") - }} -
-
- -
-
- -
-
- -
-
- {{#unless this.webauthnUnsupported}} - - {{/unless}} -
-
-
-
\ No newline at end of file