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");
+ }
+ }
+
+
+ {{#if this.errorMessage}}
+
+ {{this.errorMessage}}
+
+ {{/if}}
+
+
+
+ {{this.instructions}}
+
+
+
+ {{this.loggedInAs}}
+
+ {{this.currentUser.username}}
+
+
+
+
+
+
+ {{this.finePrint}}
+
+
+
+
+}
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);
+ }
+ }
+
+
+ {{#if this.errorMessage}}
+
+ {{this.errorMessage}}
+
+ {{/if}}
+
+
+
+}
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 },
+ });
+ }
+
+
+
+
+
+ {{this.instructions}}
+
+
+
+ {{#each @model.user_passkeys as |passkey|}}
+
+
+
{{passkey.name}}
+
+
+ {{this.addedPrefix}}
+
+ {{this.formatDate
+ passkey.created_at
+ format="medium"
+ leaveAgo="true"
+ }}
+
+
+ {{#if passkey.last_used}}
+
+ {{this.lastUsedPrefix}}
+
+ {{this.formatDate
+ passkey.last_used
+ format="medium"
+ leaveAgo="true"
+ }}
+ {{else}}
+ {{this.neverUsed}}
+ {{/if}}
+
+
+ {{#if this.isCurrentUser}}
+
+ {{/if}}
+
+ {{/each}}
+
+
+
+ {{#if this.isCurrentUser}}
+
+ {{/if}}
+
+
+
+}
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}}
-