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:
Penar Musaraj 2023-10-23 11:21:05 -04:00 committed by GitHub
parent 8d640acf86
commit b6dc929141
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 145 additions and 58 deletions

View File

@ -17,7 +17,7 @@
{{/each}}
{{#if this.canUsePasskeys}}
<PasskeyLoginButton />
<PasskeyLoginButton @passkeyLogin={{this.passkeyLogin}} />
{{/if}}
<PluginOutlet @name="after-login-buttons" />

View File

@ -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>

View File

@ -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(

View File

@ -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}}

View File

@ -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;

View File

@ -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"

View File

@ -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);
}
}
}

View File

@ -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");
});
});