From 1a70817962f9f635c4e5f56709b8a3bf310f4c02 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 13 Oct 2023 12:24:06 -0400 Subject: [PATCH] DEV: Add UI for passkeys (3/3) (#23853) Adds UI elements for registering a passkey and logging in with it. The feature is still in an early stage, interested parties that want to try it can use the `experimental_passkeys` site setting (via Rails console). See PR for more details. --------- Co-authored-by: Joffrey JAFFEUX --- .../dialog-messages/confirm-session.gjs | 85 ++++++ .../app/components/login-buttons.hbs | 5 + .../discourse/app/components/login-buttons.js | 20 +- .../discourse/app/components/modal/login.js | 11 +- .../app/components/passkey-login-button.gjs | 45 ++++ .../passkey-options-dropdown.js | 39 +++ .../user-preferences/rename-passkey.gjs | 67 +++++ .../user-preferences/user-passkeys.gjs | 253 ++++++++++++++++++ .../app/controllers/preferences/security.js | 10 + .../javascripts/discourse/app/lib/webauthn.js | 44 ++- .../javascripts/discourse/app/models/user.js | 23 ++ .../app/templates/preferences/security.hbs | 12 +- .../user-preferences-security-test.js | 84 ++++++ .../stylesheets/common/base/discourse.scss | 49 ---- app/assets/stylesheets/common/base/modal.scss | 16 ++ app/assets/stylesheets/common/base/user.scss | 84 ++++++ .../stylesheets/common/foundation/base.scss | 4 +- app/serializers/user_serializer.rb | 1 + config/locales/client.en.yml | 33 ++- spec/serializers/user_serializer_spec.rb | 14 +- .../user_preferences_security_spec.rb | 64 ++++- 21 files changed, 889 insertions(+), 74 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/dialog-messages/confirm-session.gjs create mode 100644 app/assets/javascripts/discourse/app/components/passkey-login-button.gjs create mode 100644 app/assets/javascripts/discourse/app/components/user-preferences/passkey-options-dropdown.js create mode 100644 app/assets/javascripts/discourse/app/components/user-preferences/rename-passkey.gjs create mode 100644 app/assets/javascripts/discourse/app/components/user-preferences/user-passkeys.gjs diff --git a/app/assets/javascripts/discourse/app/components/dialog-messages/confirm-session.gjs b/app/assets/javascripts/discourse/app/components/dialog-messages/confirm-session.gjs new file mode 100644 index 00000000000..be9d988d852 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/dialog-messages/confirm-session.gjs @@ -0,0 +1,85 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import UserLink from "discourse/components/user-link"; +import { ajax } from "discourse/lib/ajax"; +import I18n from "I18n"; + +export default class ConfirmSession extends Component { + @service dialog; + @service currentUser; + + @tracked errorMessage; + + passwordLabel = I18n.t("user.password.title"); + instructions = I18n.t("user.confirm_access.instructions"); + loggedInAs = I18n.t("user.confirm_access.logged_in_as"); + finePrint = I18n.t("user.confirm_access.fine_print"); + + @action + async submit() { + const result = await ajax("/u/confirm-session", { + type: "POST", + data: { + password: this.password, + }, + }); + + if (result.success) { + this.errorMessage = null; + this.dialog.didConfirmWrapped(); + } else { + this.errorMessage = I18n.t("user.confirm_access.incorrect_password"); + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/login-buttons.hbs b/app/assets/javascripts/discourse/app/components/login-buttons.hbs index d386186f9d6..2b03fb31d76 100644 --- a/app/assets/javascripts/discourse/app/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/app/components/login-buttons.hbs @@ -15,4 +15,9 @@ {{b.title}} {{/each}} + +{{#if this.canUsePasskeys}} + +{{/if}} + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/login-buttons.js b/app/assets/javascripts/discourse/app/components/login-buttons.js index 00635d5f91b..96fea691998 100644 --- a/app/assets/javascripts/discourse/app/components/login-buttons.js +++ b/app/assets/javascripts/discourse/app/components/login-buttons.js @@ -1,4 +1,5 @@ import Component from "@ember/component"; +import { isWebauthnSupported } from "discourse/lib/webauthn"; import { findAll } from "discourse/models/login-method"; import discourseComputed from "discourse-common/utils/decorators"; @@ -6,9 +7,13 @@ export default Component.extend({ elementId: "login-buttons", classNameBindings: ["hidden"], - @discourseComputed("buttons.length", "showLoginWithEmailLink") - hidden(buttonsCount, showLoginWithEmailLink) { - return buttonsCount === 0 && !showLoginWithEmailLink; + @discourseComputed( + "buttons.length", + "showLoginWithEmailLink", + "canUsePasskeys" + ) + hidden(buttonsCount, showLoginWithEmailLink, canUsePasskeys) { + return buttonsCount === 0 && !showLoginWithEmailLink && !canUsePasskeys; }, @discourseComputed @@ -16,6 +21,15 @@ export default Component.extend({ return findAll(); }, + @discourseComputed + canUsePasskeys() { + return ( + this.siteSettings.enable_local_logins && + this.siteSettings.experimental_passkeys && + isWebauthnSupported() + ); + }, + actions: { externalLogin(provider) { this.externalLogin(provider); diff --git a/app/assets/javascripts/discourse/app/components/modal/login.js b/app/assets/javascripts/discourse/app/components/modal/login.js index 72dff7d5846..2a5fd7063ae 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login.js +++ b/app/assets/javascripts/discourse/app/components/modal/login.js @@ -8,6 +8,7 @@ import { ajax } from "discourse/lib/ajax"; import cookie, { removeCookie } from "discourse/lib/cookie"; import { areCookiesEnabled } from "discourse/lib/utilities"; import { wavingHandURL } from "discourse/lib/waving-hand-url"; +import { isWebauthnSupported } from "discourse/lib/webauthn"; import { findAll } from "discourse/models/login-method"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import escape from "discourse-common/lib/escape"; @@ -86,8 +87,16 @@ export default class Login extends Component { return classes.join(" "); } + get canUsePasskeys() { + return ( + this.siteSettings.enable_local_logins && + this.siteSettings.experimental_passkeys && + isWebauthnSupported() + ); + } + get hasAtLeastOneLoginButton() { - return findAll().length > 0; + return findAll().length > 0 || this.canUsePasskeys; } get loginButtonLabel() { diff --git a/app/assets/javascripts/discourse/app/components/passkey-login-button.gjs b/app/assets/javascripts/discourse/app/components/passkey-login-button.gjs new file mode 100644 index 00000000000..2730c70b0dd --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/passkey-login-button.gjs @@ -0,0 +1,45 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { getPasskeyCredential } from "discourse/lib/webauthn"; + +export default class PasskeyLoginButton extends Component { + @service dialog; + + @action + async passkeyLogin() { + try { + const response = await ajax("/session/passkey/challenge.json"); + + const publicKeyCredential = await getPasskeyCredential( + response.challenge, + (errorMessage) => this.dialog.alert(errorMessage) + ); + + const authResult = await ajax("/session/passkey/auth.json", { + type: "POST", + data: { publicKeyCredential }, + }); + + if (authResult && !authResult.error) { + window.location.reload(); + } else { + this.dialog.alert(authResult.error); + } + } catch (e) { + popupAjaxError(e); + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/user-preferences/passkey-options-dropdown.js b/app/assets/javascripts/discourse/app/components/user-preferences/passkey-options-dropdown.js new file mode 100644 index 00000000000..442a7875f8a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-preferences/passkey-options-dropdown.js @@ -0,0 +1,39 @@ +import { action, computed } from "@ember/object"; +import I18n from "I18n"; +import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; + +export default DropdownSelectBoxComponent.extend({ + classNames: ["passkey-options-dropdown"], + + selectKitOptions: { + icon: "wrench", + showFullTitle: false, + }, + + content: computed(function () { + return [ + { + id: "edit", + icon: "pencil-alt", + name: I18n.t("user.second_factor.edit"), + }, + { + id: "delete", + icon: "trash-alt", + name: I18n.t("user.second_factor.delete"), + }, + ]; + }), + + @action + onChange(id) { + switch (id) { + case "edit": + this.renamePasskey(); + break; + case "delete": + this.deletePasskey(); + break; + } + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/user-preferences/rename-passkey.gjs b/app/assets/javascripts/discourse/app/components/user-preferences/rename-passkey.gjs new file mode 100644 index 00000000000..ea53b9a7973 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-preferences/rename-passkey.gjs @@ -0,0 +1,67 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { extractError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +export default class RenamePasskey extends Component { + @service router; + @service dialog; + + @tracked passkeyName; + @tracked errorMessage; + + instructions = I18n.t("user.passkeys.rename_passkey_instructions"); + + constructor() { + super(...arguments); + this.passkeyName = this.args.model.name; + } + + @action + async saveRename() { + try { + await ajax(`/u/rename_passkey/${this.args.model.id}`, { + type: "PUT", + data: { + name: this.passkeyName, + }, + }); + + this.errorMessage = null; + this.router.refresh(); + this.dialog.didConfirmWrapped(); + } catch (error) { + this.errorMessage = extractError(error); + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/user-preferences/user-passkeys.gjs b/app/assets/javascripts/discourse/app/components/user-preferences/user-passkeys.gjs new file mode 100644 index 00000000000..6712f70315d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/user-preferences/user-passkeys.gjs @@ -0,0 +1,253 @@ +import Component from "@glimmer/component"; +import { getOwner } from "@ember/application"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import ConfirmSession from "discourse/components/dialog-messages/confirm-session"; +import PasskeyOptionsDropdown from "discourse/components/user-preferences/passkey-options-dropdown"; +import RenamePasskey from "discourse/components/user-preferences/rename-passkey"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bufferToBase64, stringToBuffer } from "discourse/lib/webauthn"; +import I18n from "I18n"; + +export default class UserPasskeys extends Component { + @service dialog; + @service currentUser; + @service capabilities; + @service router; + + instructions = I18n.t("user.passkeys.short_description"); + title = I18n.t("user.passkeys.title"); + formatDate = getOwner(this).resolveRegistration("helper:format-date"); + addedPrefix = I18n.t("user.passkeys.added_prefix"); + lastUsedPrefix = I18n.t("user.passkeys.last_used_prefix"); + neverUsed = I18n.t("user.passkeys.never_used"); + + isCurrentUser() { + return this.currentUser.id === this.args.model.id; + } + + passkeyDefaultName() { + if (this.capabilities.isSafari) { + return I18n.t("user.passkeys.name.icloud_keychain"); + } + + if (this.capabilities.isAndroid || this.capabilities.isChrome) { + return I18n.t("user.passkeys.name.google_password_manager"); + } + + return I18n.t("user.passkeys.name.default"); + } + + async createPasskey() { + try { + const response = await this.args.model.createPasskey(); + + const publicKeyCredentialCreationOptions = { + challenge: Uint8Array.from(response.challenge, (c) => c.charCodeAt(0)), + rp: { + name: response.rp_name, + id: response.rp_id, + }, + user: { + id: Uint8Array.from(response.user_secure_id, (c) => c.charCodeAt(0)), + name: this.currentUser.username, + displayName: this.currentUser.username, + }, + pubKeyCredParams: response.supported_algorithms.map((alg) => { + return { type: "public-key", alg }; + }), + excludeCredentials: response.existing_passkey_credential_ids.map( + (credentialId) => { + return { + type: "public-key", + id: stringToBuffer(atob(credentialId)), + }; + } + ), + authenticatorSelection: { + // https://www.w3.org/TR/webauthn-2/#user-verification + // for passkeys (first factor), user verification should be marked as required + // it ensures browser prompts user for PIN/fingerprint/faceID before authenticating + userVerification: "required", + // See https://w3c.github.io/webauthn/#sctn-createCredential for context + // This ensures that the authenticator stores a client-side private key + // physical security keys (like Yubikey) need this + requireResidentKey: true, + }, + }; + + const credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + }); + + let credentialParam = { + id: credential.id, + rawId: bufferToBase64(credential.rawId), + type: credential.type, + attestation: bufferToBase64(credential.response.attestationObject), + clientData: bufferToBase64(credential.response.clientDataJSON), + name: this.passkeyDefaultName(), + }; + + const registrationResponse = await this.args.model.registerPasskey( + credentialParam + ); + + if (registrationResponse.error) { + this.dialog.alert(registrationResponse.error); + return; + } + + this.router.refresh(); + + // Prompt to rename key after creating + this.dialog.dialog({ + title: I18n.t("user.passkeys.passkey_successfully_created"), + type: "notice", + bodyComponent: RenamePasskey, + bodyComponentModel: registrationResponse, + }); + } catch (error) { + this.errorMessage = + error.name === "InvalidStateError" + ? I18n.t("user.passkeys.already_added_error") + : I18n.t("user.passkeys.not_allowed_error"); + this.dialog.alert(this.errorMessage); + } + } + + confirmDelete(id) { + schedule("afterRender", () => { + this.dialog.deleteConfirm({ + title: I18n.t("user.passkeys.confirm_delete_passkey"), + didConfirm: () => { + this.args.model.deletePasskey(id).then(() => { + this.router.refresh(); + }); + }, + }); + }); + } + + @action + async addPasskey() { + try { + const trustedSession = await this.args.model.trustedSession(); + + if (!trustedSession.success) { + this.dialog.dialog({ + title: I18n.t("user.confirm_access.title"), + type: "notice", + bodyComponent: ConfirmSession, + didConfirm: () => this.createPasskey(), + }); + } else { + await this.createPasskey(); + } + } catch (error) { + popupAjaxError(error); + } + } + + @action + async deletePasskey(id) { + try { + const trustedSession = await this.args.model.trustedSession(); + + if (!trustedSession.success) { + this.dialog.dialog({ + title: I18n.t("user.confirm_access.title"), + type: "notice", + bodyComponent: ConfirmSession, + didConfirm: () => this.confirmDelete(id), + }); + } else { + this.confirmDelete(id); + } + } catch (error) { + popupAjaxError(error); + } + } + + @action + renamePasskey(id, name) { + this.dialog.dialog({ + title: I18n.t("user.passkeys.rename_passkey"), + type: "notice", + bodyComponent: RenamePasskey, + bodyComponentModel: { id, name }, + }); + } + + +} diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/security.js b/app/assets/javascripts/discourse/app/controllers/preferences/security.js index 717643275bc..c6325f4cef3 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/security.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/security.js @@ -7,6 +7,7 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import logout from "discourse/lib/logout"; import { userPath } from "discourse/lib/url"; +import { isWebauthnSupported } from "discourse/lib/webauthn"; import CanCheckEmails from "discourse/mixins/can-check-emails"; import discourseComputed from "discourse-common/utils/decorators"; import I18n from "I18n"; @@ -20,6 +21,15 @@ export default Controller.extend(CanCheckEmails, { subpageTitle: I18n.t("user.preferences_nav.security"), showAllAuthTokens: false, + get canUsePasskeys() { + return ( + !this.siteSettings.enable_discourse_connect && + this.siteSettings.enable_local_logins && + this.siteSettings.experimental_passkeys && + isWebauthnSupported() + ); + }, + @discourseComputed("model.is_anonymous") canChangePassword(isAnonymous) { if (isAnonymous) { diff --git a/app/assets/javascripts/discourse/app/lib/webauthn.js b/app/assets/javascripts/discourse/app/lib/webauthn.js index 3fe71998481..178b914d1f1 100644 --- a/app/assets/javascripts/discourse/app/lib/webauthn.js +++ b/app/assets/javascripts/discourse/app/lib/webauthn.js @@ -43,11 +43,10 @@ export function getWebauthnCredential( publicKey: { challenge: challengeBuffer, 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 necessarily what we want, it limits webauthn to only devices that support - // user verification, which usually requires entering a PIN + timeout: 60000, // this is just a hint + // in the backend, we don't check for user verification for 2FA + // therefore we should indicate to browser that it's not necessary + // (this is only a hint, though, browser may still prompt) userVerification: "discouraged", }, }) @@ -94,3 +93,38 @@ export function getWebauthnCredential( errorCallback(err); }); } + +export async function getPasskeyCredential(challenge, errorCallback) { + if (!isWebauthnSupported()) { + return errorCallback(I18n.t("login.security_key_support_missing_error")); + } + + return navigator.credentials + .get({ + publicKey: { + challenge: stringToBuffer(challenge), + // https://www.w3.org/TR/webauthn-2/#user-verification + // for passkeys (first factor), user verification should be marked as required + // it ensures browser requests PIN or biometrics before authenticating + // lib/discourse_webauthn/authentication_service.rb requires this flag too + userVerification: "required", + }, + }) + .then((credential) => { + return { + signature: bufferToBase64(credential.response.signature), + clientData: bufferToBase64(credential.response.clientDataJSON), + authenticatorData: bufferToBase64( + credential.response.authenticatorData + ), + credentialId: bufferToBase64(credential.rawId), + userHandle: bufferToBase64(credential.response.userHandle), + }; + }) + .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/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 1d7c0b47ba6..511649c13d2 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -560,6 +560,29 @@ const User = RestModel.extend({ }); }, + trustedSession() { + return ajax("/u/trusted-session.json"); + }, + + createPasskey() { + return ajax("/u/create_passkey.json", { + type: "POST", + }); + }, + + registerPasskey(credential) { + return ajax("/u/register_passkey.json", { + data: credential, + type: "POST", + }); + }, + + deletePasskey(id) { + return ajax(`/u/delete_passkey/${id}`, { + type: "DELETE", + }); + }, + createSecondFactorTotp() { return ajax("/u/create_second_factor_totp.json", { type: "POST", diff --git a/app/assets/javascripts/discourse/app/templates/preferences/security.hbs b/app/assets/javascripts/discourse/app/templates/preferences/security.hbs index 8803ac7d9ce..a16cd5ae82d 100644 --- a/app/assets/javascripts/discourse/app/templates/preferences/security.hbs +++ b/app/assets/javascripts/discourse/app/templates/preferences/security.hbs @@ -15,15 +15,19 @@ + {{#if this.canUsePasskeys}} + + {{/if}} +
{{#unless this.model.second_factor_enabled}} -