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 <j.jaffeux@gmail.com>
This commit is contained in:
parent
a5858e60e1
commit
1a70817962
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.errorMessage}}
|
||||
<div class="alert alert-error">
|
||||
{{this.errorMessage}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="control-group confirm-session">
|
||||
<div class="confirm-session__instructions">
|
||||
{{this.instructions}}
|
||||
</div>
|
||||
|
||||
<div class="confirm-session__instructions">
|
||||
<span>{{this.loggedInAs}}</span>
|
||||
<UserLink @user={{this.currentUser}}>
|
||||
{{this.currentUser.username}}
|
||||
</UserLink>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<label class="control-label">{{this.passwordLabel}}</label>
|
||||
<div class="controls">
|
||||
<div class="inline-form">
|
||||
<Input
|
||||
@value={{this.password}}
|
||||
@type="password"
|
||||
id="password"
|
||||
class="input-large"
|
||||
autofocus="autofocus"
|
||||
/>
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@type="submit"
|
||||
@action={{this.submit}}
|
||||
@label="user.password.confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="confirm-session__fine-print">
|
||||
{{this.finePrint}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -15,4 +15,9 @@
|
|||
{{b.title}}
|
||||
</button>
|
||||
{{/each}}
|
||||
|
||||
{{#if this.canUsePasskeys}}
|
||||
<PasskeyLoginButton />
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet @name="after-login-buttons" />
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
@action={{this.passkeyLogin}}
|
||||
@icon="user"
|
||||
@label="login.passkey.name"
|
||||
class="btn btn-social passkey-login-button"
|
||||
/>
|
||||
</template>
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.errorMessage}}
|
||||
<div class="alert alert-error">
|
||||
{{this.errorMessage}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="rename-passkey__form">
|
||||
<div class="rename-passkey__message">
|
||||
<p>{{this.instructions}}</p>
|
||||
</div>
|
||||
<form>
|
||||
<div class="rename-passkey__form inline-form">
|
||||
<Input @value={{this.passkeyName}} autofocus={{true}} @type="text" />
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@type="submit"
|
||||
@action={{this.saveRename}}
|
||||
@label="user.passkeys.save"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="control-group pref-passkeys">
|
||||
<label class="control-label">
|
||||
{{this.title}}
|
||||
</label>
|
||||
<div class="instructions">
|
||||
{{this.instructions}}
|
||||
</div>
|
||||
|
||||
<div class="pref-passkeys__rows">
|
||||
{{#each @model.user_passkeys as |passkey|}}
|
||||
<div class="row">
|
||||
<div class="passkey-left">
|
||||
<div class="row-passkey__name">{{passkey.name}}</div>
|
||||
<div class="row-passkey__created-date">
|
||||
<span class="prefix">
|
||||
{{this.addedPrefix}}
|
||||
</span>
|
||||
{{this.formatDate
|
||||
passkey.created_at
|
||||
format="medium"
|
||||
leaveAgo="true"
|
||||
}}
|
||||
</div>
|
||||
<div class="row-passkey__used-date">
|
||||
{{#if passkey.last_used}}
|
||||
<span class="prefix">
|
||||
{{this.lastUsedPrefix}}
|
||||
</span>
|
||||
{{this.formatDate
|
||||
passkey.last_used
|
||||
format="medium"
|
||||
leaveAgo="true"
|
||||
}}
|
||||
{{else}}
|
||||
{{this.neverUsed}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.isCurrentUser}}
|
||||
<div class="passkey-right">
|
||||
<div class="actions">
|
||||
<PasskeyOptionsDropdown
|
||||
@deletePasskey={{fn this.deletePasskey passkey.id}}
|
||||
@renamePasskey={{fn
|
||||
this.renamePasskey
|
||||
passkey.id
|
||||
passkey.name
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="controls pref-passkeys__add">
|
||||
{{#if this.isCurrentUser}}
|
||||
<DButton
|
||||
@action={{this.addPasskey}}
|
||||
@icon="plus"
|
||||
@label="user.passkeys.add_passkey"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -15,15 +15,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.canUsePasskeys}}
|
||||
<UserPreferences::UserPasskeys @model={{@model}} />
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
class="control-group pref-second-factor"
|
||||
data-setting-name="user-second-factor"
|
||||
>
|
||||
<label class="control-label">{{i18n "user.second_factor.title"}}</label>
|
||||
{{#unless this.model.second_factor_enabled}}
|
||||
<label>
|
||||
<div class="instructions">
|
||||
{{i18n "user.second_factor.short_description"}}
|
||||
</label>
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="controls pref-second-factor">
|
||||
{{#if this.isCurrentUser}}
|
||||
|
@ -45,7 +49,9 @@
|
|||
data-setting-name="user-auth-tokens"
|
||||
>
|
||||
<label class="control-label">{{i18n "user.auth_tokens.title"}}</label>
|
||||
|
||||
<div class="instructions">
|
||||
{{i18n "user.auth_tokens.short_description"}}
|
||||
</div>
|
||||
<div class="auth-tokens">
|
||||
{{#each this.authTokens as |token|}}
|
||||
<div class="row auth-token">
|
||||
|
|
|
@ -17,6 +17,10 @@ acceptance("User Preferences - Security", function (needs) {
|
|||
server.get("/u/eviltrout/activity.json", () => {
|
||||
return helper.response({});
|
||||
});
|
||||
|
||||
server.get("/u/trusted-session.json", () => {
|
||||
return helper.response({ failed: "FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
test("recently connected devices", async function (assert) {
|
||||
|
@ -93,4 +97,84 @@ acceptance("User Preferences - Security", function (needs) {
|
|||
"displays the last used at date for the API key"
|
||||
);
|
||||
});
|
||||
|
||||
test("Viewing Passkeys - user has a key", async function (assert) {
|
||||
this.siteSettings.experimental_passkeys = true;
|
||||
|
||||
updateCurrentUser({
|
||||
user_passkeys: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Password Manager",
|
||||
last_used: "2023-10-09T20:03:20.986Z",
|
||||
created_at: "2023-10-09T20:01:37.578Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await visit("/u/eviltrout/preferences/security");
|
||||
|
||||
assert.strictEqual(
|
||||
query(".pref-passkeys__rows .row-passkey__name").innerText.trim(),
|
||||
"Password Manager",
|
||||
"displays the passkey name"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(".row-passkey__created-date")
|
||||
.exists("displays the created at date for the passkey");
|
||||
|
||||
assert
|
||||
.dom(".row-passkey__used-date")
|
||||
.exists("displays the last used at date for the passkey");
|
||||
|
||||
await click(".pref-passkeys__add button");
|
||||
|
||||
assert
|
||||
.dom(".dialog-body .confirm-session")
|
||||
.exists(
|
||||
"displays a dialog to confirm the user's identity before adding a passkey"
|
||||
);
|
||||
|
||||
await click(".dialog-close");
|
||||
|
||||
const dropdown = selectKit(".passkey-options-dropdown");
|
||||
await dropdown.expand();
|
||||
await dropdown.selectRowByName("Edit");
|
||||
|
||||
assert
|
||||
.dom(".dialog-body .rename-passkey__form")
|
||||
.exists("clicking Edit displays a dialog to rename the passkey");
|
||||
|
||||
await click(".dialog-close");
|
||||
|
||||
await dropdown.expand();
|
||||
await dropdown.selectRowByName("Delete");
|
||||
|
||||
assert
|
||||
.dom(".dialog-body .confirm-session")
|
||||
.exists(
|
||||
"displays a dialog to confirm the user's identity before deleting a passkey"
|
||||
);
|
||||
|
||||
await click(".dialog-close");
|
||||
});
|
||||
|
||||
test("Viewing Passkeys - empty state", async function (assert) {
|
||||
this.siteSettings.experimental_passkeys = true;
|
||||
|
||||
await visit("/u/eviltrout/preferences/security");
|
||||
|
||||
assert
|
||||
.dom(".pref-passkeys__add .btn")
|
||||
.exists("shows a button to add a passkey");
|
||||
|
||||
await click(".pref-passkeys__add .btn");
|
||||
|
||||
assert
|
||||
.dom(".dialog-body .confirm-session")
|
||||
.exists(
|
||||
"displays a dialog to confirm the user's identity before adding a passkey"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -711,55 +711,6 @@ table {
|
|||
}
|
||||
}
|
||||
|
||||
.pref-auth-tokens {
|
||||
.row {
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin: 5px 0px;
|
||||
padding-bottom: 5px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-icon {
|
||||
color: var(--primary-medium);
|
||||
font-size: 2.25em;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.auth-token-first {
|
||||
font-size: 1.1em;
|
||||
|
||||
.auth-token-device {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-second {
|
||||
color: var(--primary-medium);
|
||||
|
||||
.active {
|
||||
color: var(--success);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-dropdown {
|
||||
float: right;
|
||||
|
||||
.btn,
|
||||
.btn:hover {
|
||||
background: transparent;
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topic-statuses {
|
||||
// avoid adding margin/padding on this parent; sometimes it appears as an empty container
|
||||
float: left;
|
||||
|
|
|
@ -790,3 +790,19 @@
|
|||
@extend .btn-flat;
|
||||
@extend .btn-small;
|
||||
}
|
||||
|
||||
.confirm-session {
|
||||
&__instructions {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
&__fine-print {
|
||||
font-size: var(--font-down-1);
|
||||
color: var(--primary-medium);
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -651,6 +651,90 @@
|
|||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.pref-auth-tokens {
|
||||
.auth-token-icon {
|
||||
color: var(--primary-medium);
|
||||
font-size: 2.25em;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.auth-token-first {
|
||||
font-size: 1.1em;
|
||||
|
||||
.auth-token-device {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-second {
|
||||
color: var(--primary-medium);
|
||||
|
||||
.active {
|
||||
color: var(--success);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-dropdown {
|
||||
float: right;
|
||||
|
||||
.btn,
|
||||
.btn:hover {
|
||||
background: transparent;
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pref-passkeys,
|
||||
.pref-auth-tokens {
|
||||
.row {
|
||||
border-top: 1px solid var(--primary-low);
|
||||
padding: 0.5em 0;
|
||||
margin: 0.5em 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pref-passkeys {
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.row-passkey__name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.row-passkey__created-date,
|
||||
.row-passkey__used-date {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.passkey-options-dropdown {
|
||||
.btn,
|
||||
.btn:hover {
|
||||
background: transparent;
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paginated-topics-list {
|
||||
|
|
|
@ -180,7 +180,8 @@ input[type="submit"] {
|
|||
}
|
||||
|
||||
> input[type="text"],
|
||||
> input[type="search"] {
|
||||
> input[type="search"],
|
||||
> input[type="password"] {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -188,6 +189,7 @@ input[type="submit"] {
|
|||
> .select-kit,
|
||||
> input[type="text"],
|
||||
> input[type="search"],
|
||||
> input[type="password"],
|
||||
> label,
|
||||
> .btn,
|
||||
> .d-date-input {
|
||||
|
|
|
@ -168,6 +168,7 @@ class UserSerializer < UserCardSerializer
|
|||
def user_passkeys
|
||||
UserSecurityKey
|
||||
.where(user_id: object.id, factor_type: UserSecurityKey.factor_types[:first_factor])
|
||||
.order("created_at ASC")
|
||||
.map do |usk|
|
||||
{ id: usk.id, name: usk.name, last_used: usk.last_used, created_at: usk.created_at }
|
||||
end
|
||||
|
|
|
@ -1471,7 +1471,7 @@ en:
|
|||
disable_description: "Please enter the authentication code from your app"
|
||||
show_key_description: "Enter manually"
|
||||
short_description: |
|
||||
Protect your account with one-time use security codes.
|
||||
Protect your account with one-time use security codes or physical security keys.
|
||||
extended_description: |
|
||||
Two-factor authentication adds extra security to your account by requiring a one-time token in addition to your password. Tokens can be generated on <a href="https://www.google.com/search?q=authenticator+apps+for+android" target='_blank'>Android</a> and <a href="https://www.google.com/search?q=authenticator+apps+for+ios">iOS</a> devices.
|
||||
oauth_enabled_warning: "Please note that social logins will be disabled once two-factor authentication has been enabled on your account."
|
||||
|
@ -1510,6 +1510,24 @@ en:
|
|||
save: "Save"
|
||||
edit_description: "Physical Security Key Name"
|
||||
name_required_error: "You must provide a name for your security key."
|
||||
passkeys:
|
||||
rename_passkey: "Rename Passkey"
|
||||
add_passkey: "Add Passkey"
|
||||
confirm_delete_passkey: "Are you sure you want to delete this passkey?"
|
||||
passkey_successfully_created: "Success! Your new passkey was created."
|
||||
rename_passkey_instructions: "Pick a passkey name that will easily identify it for you, for example, use the name of your password manager."
|
||||
name:
|
||||
default: "Main Passkey"
|
||||
google_password_manager: "Google Password Manager"
|
||||
icloud_keychain: "iCloud Keychain"
|
||||
save: "Save"
|
||||
title: "Passkeys"
|
||||
short_description: "Passkeys are password replacements that validate your identity biometrically (e.g. touch, faceID) or via a device PIN/password."
|
||||
added_prefix: "Added"
|
||||
last_used_prefix: "Last Used"
|
||||
never_used: "Never Used"
|
||||
not_allowed_error: "The passkey registration process either timed out or was cancelled."
|
||||
already_added_error: "You have already registered this passkey. You don’t have to register it again."
|
||||
|
||||
change_about:
|
||||
title: "Change About Me"
|
||||
|
@ -1635,6 +1653,7 @@ en:
|
|||
|
||||
auth_tokens:
|
||||
title: "Recently Used Devices"
|
||||
short_description: "This is a list of devices that have recently logged into your account."
|
||||
details: "Details"
|
||||
log_out_all: "Log out all"
|
||||
not_you: "Not you?"
|
||||
|
@ -1825,6 +1844,12 @@ en:
|
|||
success: "File uploaded successfully. You will be notified via message when the process is complete."
|
||||
error: "Sorry, file should be CSV format."
|
||||
|
||||
confirm_access:
|
||||
title: "Confirm access"
|
||||
incorrect_password: "The entered password is incorrect."
|
||||
logged_in_as: "You are logged in as: "
|
||||
instructions: "Please confirm your identity in order to complete this action."
|
||||
fine_print: "We are asking you to confirm your identity because this is a potentially sensitive action. Once authenticated, you will only be asked to re-authenticate again after a few hours of inactivity."
|
||||
password:
|
||||
title: "Password"
|
||||
too_short: "Your password is too short."
|
||||
|
@ -1834,6 +1859,8 @@ en:
|
|||
ok: "Your password looks good."
|
||||
instructions: "at least %{count} characters"
|
||||
required: "Please enter a password"
|
||||
confirm: "Confirm"
|
||||
incorrect_password: "The entered password is incorrect."
|
||||
|
||||
summary:
|
||||
title: "Summary"
|
||||
|
@ -2233,6 +2260,8 @@ en:
|
|||
name: "Discord"
|
||||
title: "Log in with Discord"
|
||||
sr_title: "Log in with Discord"
|
||||
passkey:
|
||||
name: "Login with a passkey"
|
||||
second_factor_toggle:
|
||||
totp: "Use an authenticator app instead"
|
||||
backup_code: "Use a backup code instead"
|
||||
|
@ -5145,7 +5174,7 @@ en:
|
|||
install: "Install"
|
||||
delete: "Delete"
|
||||
delete_confirm: 'Are you sure you want to delete "%{theme_name}"?'
|
||||
bulk_delete: 'Are you sure?'
|
||||
bulk_delete: "Are you sure?"
|
||||
bulk_themes_delete_confirm: "This will uninstall the following themes, they will no longer be useable by any users on your site:"
|
||||
bulk_components_delete_confirm: "This will uninstall the following components, they will no longer be useable by any users on your site:"
|
||||
color: "Color"
|
||||
|
|
|
@ -442,7 +442,12 @@ RSpec.describe UserSerializer do
|
|||
|
||||
context "with user_passkeys" do
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
fab!(:passkey) { Fabricate(:passkey_with_random_credential, user: user) }
|
||||
fab!(:passkey0) do
|
||||
Fabricate(:passkey_with_random_credential, user: user, created_at: 5.hours.ago)
|
||||
end
|
||||
fab!(:passkey1) do
|
||||
Fabricate(:passkey_with_random_credential, user: user, created_at: 2.hours.ago)
|
||||
end
|
||||
|
||||
it "does not include them if feature is disabled" do
|
||||
json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
|
||||
|
@ -455,9 +460,10 @@ RSpec.describe UserSerializer do
|
|||
|
||||
json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
|
||||
|
||||
expect(json[:user_passkeys][0][:id]).to eq(passkey.id)
|
||||
expect(json[:user_passkeys][0][:name]).to eq(passkey.name)
|
||||
expect(json[:user_passkeys][0][:last_used]).to eq(passkey.last_used)
|
||||
expect(json[:user_passkeys][0][:id]).to eq(passkey0.id)
|
||||
expect(json[:user_passkeys][0][:name]).to eq(passkey0.name)
|
||||
expect(json[:user_passkeys][0][:last_used]).to eq(passkey0.last_used)
|
||||
expect(json[:user_passkeys][1][:id]).to eq(passkey1.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -10,16 +10,15 @@ describe "User preferences for Security", type: :system do
|
|||
before do
|
||||
user.activate
|
||||
sign_in(user)
|
||||
|
||||
# system specs run on their own host + port
|
||||
DiscourseWebauthn.stubs(:origin).returns(current_host + ":" + Capybara.server_port.to_s)
|
||||
end
|
||||
|
||||
describe "Security keys" do
|
||||
it "adds a 2F security key and logs in with it" do
|
||||
# system specs run on their own host + port
|
||||
DiscourseWebauthn.stubs(:origin).returns(current_host + ":" + Capybara.server_port.to_s)
|
||||
|
||||
# simulate browser credential authorization
|
||||
it "adds a 2FA security key and logs in with it" do
|
||||
options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new
|
||||
page.driver.browser.add_virtual_authenticator(options)
|
||||
authenticator = page.driver.browser.add_virtual_authenticator(options)
|
||||
|
||||
user_preferences_security_page.visit(user)
|
||||
user_preferences_security_page.visit_second_factor(password)
|
||||
|
@ -43,6 +42,59 @@ describe "User preferences for Security", type: :system do
|
|||
find("#security-key .btn-primary").click
|
||||
|
||||
expect(page).to have_css(".header-dropdown-toggle.current-user")
|
||||
|
||||
# clear authenticator (otherwise it will interfere with other tests)
|
||||
authenticator.remove!
|
||||
end
|
||||
end
|
||||
|
||||
describe "Passkeys" do
|
||||
before { SiteSetting.experimental_passkeys = true }
|
||||
|
||||
it "adds a passkey and logs in with it" do
|
||||
options =
|
||||
::Selenium::WebDriver::VirtualAuthenticatorOptions.new(
|
||||
user_verification: true,
|
||||
user_verified: true,
|
||||
resident_key: true,
|
||||
)
|
||||
authenticator = page.driver.browser.add_virtual_authenticator(options)
|
||||
|
||||
user_preferences_security_page.visit(user)
|
||||
|
||||
find(".pref-passkeys__add .btn").click
|
||||
expect(user_preferences_security_page).to have_css("input#password")
|
||||
|
||||
find(".dialog-body input#password").fill_in(with: password)
|
||||
find(".confirm-session .btn-primary").click
|
||||
|
||||
expect(user_preferences_security_page).to have_css(".rename-passkey__form")
|
||||
|
||||
find(".dialog-close").click
|
||||
|
||||
expect(user_preferences_security_page).to have_css(".pref-passkeys__rows .row")
|
||||
|
||||
select_kit = PageObjects::Components::SelectKit.new(".passkey-options-dropdown")
|
||||
select_kit.expand
|
||||
select_kit.select_row_by_name("Delete")
|
||||
|
||||
# confirm deletion screen shown without requiring session confirmation
|
||||
# since this was already done when adding the passkey
|
||||
expect(user_preferences_security_page).to have_css(".dialog-footer .btn-danger")
|
||||
|
||||
# close the dialog (don't delete the key, we need it to login in the next step)
|
||||
find(".dialog-close").click
|
||||
|
||||
user_menu.sign_out
|
||||
|
||||
# login with the key we just created
|
||||
find(".d-header .login-button").click
|
||||
find(".passkey-login-button").click
|
||||
|
||||
expect(page).to have_css(".header-dropdown-toggle.current-user")
|
||||
|
||||
# clear authenticator (otherwise it will interfere with other tests)
|
||||
authenticator.remove!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue