UX: Add conditional UI for passkeys (#24041)
This allows users to see their passkeys recommended by the browser as they type their username. There's a small refactor here, to make sure the same action is used by both the conditional UI and the passkey login button. The webauthn API only supports one auth attempt at a time, so in this PR we need to add a service singleton to manage the navigator.credentials.get promise so that it can be cancelled and reused as the user picks the conditional UI (i.e. the username login input) or the dedicated passkey login button.
This commit is contained in:
parent
8d640acf86
commit
b6dc929141
|
@ -17,7 +17,7 @@
|
|||
{{/each}}
|
||||
|
||||
{{#if this.canUsePasskeys}}
|
||||
<PasskeyLoginButton />
|
||||
<PasskeyLoginButton @passkeyLogin={{this.passkeyLogin}} />
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet @name="after-login-buttons" />
|
|
@ -31,6 +31,8 @@
|
|||
@loginName={{this.loginName}}
|
||||
@loginNameChanged={{this.loginNameChanged}}
|
||||
@canLoginLocalWithEmail={{this.canLoginLocalWithEmail}}
|
||||
@canUsePasskeys={{this.canUsePasskeys}}
|
||||
@passkeyLogin={{this.passkeyLogin}}
|
||||
@loginPassword={{this.loginPassword}}
|
||||
@secondFactorMethod={{this.secondFactorMethod}}
|
||||
@secondFactorToken={{this.secondFactorToken}}
|
||||
|
@ -67,7 +69,10 @@
|
|||
</div>
|
||||
{{/unless}}
|
||||
<div class="login-right-side">
|
||||
<LoginButtons @externalLogin={{this.externalLogin}} />
|
||||
<LoginButtons
|
||||
@externalLogin={{this.externalLogin}}
|
||||
@passkeyLogin={{this.passkeyLogin}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</:body>
|
||||
|
|
|
@ -5,10 +5,14 @@ import { schedule } from "@ember/runloop";
|
|||
import { inject as service } from "@ember/service";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
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 {
|
||||
getPasskeyCredential,
|
||||
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";
|
||||
|
@ -109,6 +113,34 @@ export default class Login extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
@action
|
||||
async passkeyLogin(mediation = "optional") {
|
||||
try {
|
||||
const response = await ajax("/session/passkey/challenge.json");
|
||||
|
||||
const publicKeyCredential = await getPasskeyCredential(
|
||||
response.challenge,
|
||||
(errorMessage) => this.dialog.alert(errorMessage),
|
||||
mediation
|
||||
);
|
||||
|
||||
if (publicKeyCredential) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
preloadLogin() {
|
||||
const prefillUsername = document.querySelector(
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<form id="login-form" method="post">
|
||||
<div id="credentials" class={{this.credentialsClass}}>
|
||||
<div class="input-group">
|
||||
<div class="input-group" {{did-insert this.passkeyConditionalLogin}}>
|
||||
<Input
|
||||
@value={{@loginName}}
|
||||
@type="email"
|
||||
id="login-account-name"
|
||||
class={{value-entered @loginName}}
|
||||
autocomplete="username"
|
||||
autocomplete={{if @canUsePasskeys "username webauthn" "username"}}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
disabled={{@showSecondFactor}}
|
||||
|
|
|
@ -34,6 +34,19 @@ export default class LocalLoginBody extends Component {
|
|||
return this.args.showSecondFactor || this.args.showSecurityKey;
|
||||
}
|
||||
|
||||
@action
|
||||
passkeyConditionalLogin() {
|
||||
if (
|
||||
// eslint-disable-next-line no-undef
|
||||
!PublicKeyCredential.isConditionalMediationAvailable ||
|
||||
!this.args.canUsePasskeys
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.args.passkeyLogin("conditional");
|
||||
}
|
||||
|
||||
@action
|
||||
togglePasswordMask() {
|
||||
this.maskPassword = !this.maskPassword;
|
||||
|
|
|
@ -1,42 +1,10 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
@action={{this.passkeyLogin}}
|
||||
@action={{@passkeyLogin}}
|
||||
@icon="user"
|
||||
@label="login.passkey.name"
|
||||
class="btn btn-social passkey-login-button"
|
||||
|
|
|
@ -94,13 +94,38 @@ export function getWebauthnCredential(
|
|||
});
|
||||
}
|
||||
|
||||
export async function getPasskeyCredential(challenge, errorCallback) {
|
||||
// The webauthn API only supports one auth attempt at a time
|
||||
// We need this service to cancel the previous attempt when a new one is started
|
||||
class WebauthnAbortService {
|
||||
controller = undefined;
|
||||
|
||||
signal() {
|
||||
if (this.controller) {
|
||||
const abortError = new Error("Cancelling pending webauthn call");
|
||||
abortError.name = "AbortError";
|
||||
this.controller.abort(abortError);
|
||||
}
|
||||
|
||||
this.controller = new AbortController();
|
||||
return this.controller.signal;
|
||||
}
|
||||
}
|
||||
|
||||
// Need to use a singleton here to reset the active webauthn ceremony
|
||||
// Inspired by the BaseWebAuthnAbortService in https://github.com/MasterKale/SimpleWebAuthn
|
||||
const WebauthnAbortHandler = new WebauthnAbortService();
|
||||
|
||||
export async function getPasskeyCredential(
|
||||
challenge,
|
||||
errorCallback,
|
||||
mediation = "optional"
|
||||
) {
|
||||
if (!isWebauthnSupported()) {
|
||||
return errorCallback(I18n.t("login.security_key_support_missing_error"));
|
||||
}
|
||||
|
||||
return navigator.credentials
|
||||
.get({
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: stringToBuffer(challenge),
|
||||
// https://www.w3.org/TR/webauthn-2/#user-verification
|
||||
|
@ -109,22 +134,27 @@ export async function getPasskeyCredential(challenge, errorCallback) {
|
|||
// 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);
|
||||
signal: WebauthnAbortHandler.signal(),
|
||||
mediation,
|
||||
});
|
||||
|
||||
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 (error) {
|
||||
if (error.name === "NotAllowedError") {
|
||||
return errorCallback(I18n.t("login.security_key_not_allowed_error"));
|
||||
} else if (error.name === "AbortError") {
|
||||
// no need to show an error when the cancelling a pending ceremony
|
||||
// this happens when switching from the conditional method (username input autofill)
|
||||
// to the optional method (login button) or vice versa
|
||||
return null;
|
||||
} else {
|
||||
return errorCallback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,3 +43,42 @@ acceptance("Modal - Login - With 2FA", function (needs) {
|
|||
assert.dom("#login-button").isFocused();
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Modal - Login - With Passkeys enabled", function (needs) {
|
||||
needs.settings({
|
||||
experimental_passkeys: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.get(`/session/passkey/challenge.json`, () =>
|
||||
helper.response({
|
||||
challenge: "some-challenge",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("Includes passkeys button and conditional UI", async function (assert) {
|
||||
await visit("/");
|
||||
await click("header .login-button");
|
||||
|
||||
assert.dom(".passkey-login-button").exists();
|
||||
|
||||
assert
|
||||
.dom("#login-account-name")
|
||||
.hasAttribute("autocomplete", "username webauthn");
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Modal - Login - With Passkeys disabled", function (needs) {
|
||||
needs.settings({
|
||||
experimental_passkeys: false,
|
||||
});
|
||||
|
||||
test("Excludes passkeys button and conditional UI", async function (assert) {
|
||||
await visit("/");
|
||||
await click("header .login-button");
|
||||
|
||||
assert.dom(".passkey-login-button").doesNotExist();
|
||||
assert.dom("#login-account-name").hasAttribute("autocomplete", "username");
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue