mirror of
https://github.com/discourse/discourse.git
synced 2025-03-20 10:58:54 +08:00
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}}
|
{{b.title}}
|
||||||
</button>
|
</button>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
{{#if this.canUsePasskeys}}
|
||||||
|
<PasskeyLoginButton />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<PluginOutlet @name="after-login-buttons" />
|
<PluginOutlet @name="after-login-buttons" />
|
@ -1,4 +1,5 @@
|
|||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
|
import { isWebauthnSupported } from "discourse/lib/webauthn";
|
||||||
import { findAll } from "discourse/models/login-method";
|
import { findAll } from "discourse/models/login-method";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
@ -6,9 +7,13 @@ export default Component.extend({
|
|||||||
elementId: "login-buttons",
|
elementId: "login-buttons",
|
||||||
classNameBindings: ["hidden"],
|
classNameBindings: ["hidden"],
|
||||||
|
|
||||||
@discourseComputed("buttons.length", "showLoginWithEmailLink")
|
@discourseComputed(
|
||||||
hidden(buttonsCount, showLoginWithEmailLink) {
|
"buttons.length",
|
||||||
return buttonsCount === 0 && !showLoginWithEmailLink;
|
"showLoginWithEmailLink",
|
||||||
|
"canUsePasskeys"
|
||||||
|
)
|
||||||
|
hidden(buttonsCount, showLoginWithEmailLink, canUsePasskeys) {
|
||||||
|
return buttonsCount === 0 && !showLoginWithEmailLink && !canUsePasskeys;
|
||||||
},
|
},
|
||||||
|
|
||||||
@discourseComputed
|
@discourseComputed
|
||||||
@ -16,6 +21,15 @@ export default Component.extend({
|
|||||||
return findAll();
|
return findAll();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@discourseComputed
|
||||||
|
canUsePasskeys() {
|
||||||
|
return (
|
||||||
|
this.siteSettings.enable_local_logins &&
|
||||||
|
this.siteSettings.experimental_passkeys &&
|
||||||
|
isWebauthnSupported()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
externalLogin(provider) {
|
externalLogin(provider) {
|
||||||
this.externalLogin(provider);
|
this.externalLogin(provider);
|
||||||
|
@ -8,6 +8,7 @@ import { ajax } from "discourse/lib/ajax";
|
|||||||
import cookie, { removeCookie } from "discourse/lib/cookie";
|
import cookie, { removeCookie } from "discourse/lib/cookie";
|
||||||
import { areCookiesEnabled } from "discourse/lib/utilities";
|
import { areCookiesEnabled } from "discourse/lib/utilities";
|
||||||
import { wavingHandURL } from "discourse/lib/waving-hand-url";
|
import { wavingHandURL } from "discourse/lib/waving-hand-url";
|
||||||
|
import { isWebauthnSupported } from "discourse/lib/webauthn";
|
||||||
import { findAll } from "discourse/models/login-method";
|
import { findAll } from "discourse/models/login-method";
|
||||||
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||||
import escape from "discourse-common/lib/escape";
|
import escape from "discourse-common/lib/escape";
|
||||||
@ -86,8 +87,16 @@ export default class Login extends Component {
|
|||||||
return classes.join(" ");
|
return classes.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canUsePasskeys() {
|
||||||
|
return (
|
||||||
|
this.siteSettings.enable_local_logins &&
|
||||||
|
this.siteSettings.experimental_passkeys &&
|
||||||
|
isWebauthnSupported()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
get hasAtLeastOneLoginButton() {
|
get hasAtLeastOneLoginButton() {
|
||||||
return findAll().length > 0;
|
return findAll().length > 0 || this.canUsePasskeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
get loginButtonLabel() {
|
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 { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import logout from "discourse/lib/logout";
|
import logout from "discourse/lib/logout";
|
||||||
import { userPath } from "discourse/lib/url";
|
import { userPath } from "discourse/lib/url";
|
||||||
|
import { isWebauthnSupported } from "discourse/lib/webauthn";
|
||||||
import CanCheckEmails from "discourse/mixins/can-check-emails";
|
import CanCheckEmails from "discourse/mixins/can-check-emails";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
@ -20,6 +21,15 @@ export default Controller.extend(CanCheckEmails, {
|
|||||||
subpageTitle: I18n.t("user.preferences_nav.security"),
|
subpageTitle: I18n.t("user.preferences_nav.security"),
|
||||||
showAllAuthTokens: false,
|
showAllAuthTokens: false,
|
||||||
|
|
||||||
|
get canUsePasskeys() {
|
||||||
|
return (
|
||||||
|
!this.siteSettings.enable_discourse_connect &&
|
||||||
|
this.siteSettings.enable_local_logins &&
|
||||||
|
this.siteSettings.experimental_passkeys &&
|
||||||
|
isWebauthnSupported()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
@discourseComputed("model.is_anonymous")
|
@discourseComputed("model.is_anonymous")
|
||||||
canChangePassword(isAnonymous) {
|
canChangePassword(isAnonymous) {
|
||||||
if (isAnonymous) {
|
if (isAnonymous) {
|
||||||
|
@ -43,11 +43,10 @@ export function getWebauthnCredential(
|
|||||||
publicKey: {
|
publicKey: {
|
||||||
challenge: challengeBuffer,
|
challenge: challengeBuffer,
|
||||||
allowCredentials,
|
allowCredentials,
|
||||||
timeout: 60000,
|
timeout: 60000, // this is just a hint
|
||||||
|
// in the backend, we don't check for user verification for 2FA
|
||||||
// see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why
|
// therefore we should indicate to browser that it's not necessary
|
||||||
// default value of preferred is not necessarily what we want, it limits webauthn to only devices that support
|
// (this is only a hint, though, browser may still prompt)
|
||||||
// user verification, which usually requires entering a PIN
|
|
||||||
userVerification: "discouraged",
|
userVerification: "discouraged",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -94,3 +93,38 @@ export function getWebauthnCredential(
|
|||||||
errorCallback(err);
|
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() {
|
createSecondFactorTotp() {
|
||||||
return ajax("/u/create_second_factor_totp.json", {
|
return ajax("/u/create_second_factor_totp.json", {
|
||||||
type: "POST",
|
type: "POST",
|
||||||
|
@ -15,15 +15,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.canUsePasskeys}}
|
||||||
|
<UserPreferences::UserPasskeys @model={{@model}} />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="control-group pref-second-factor"
|
class="control-group pref-second-factor"
|
||||||
data-setting-name="user-second-factor"
|
data-setting-name="user-second-factor"
|
||||||
>
|
>
|
||||||
<label class="control-label">{{i18n "user.second_factor.title"}}</label>
|
<label class="control-label">{{i18n "user.second_factor.title"}}</label>
|
||||||
{{#unless this.model.second_factor_enabled}}
|
{{#unless this.model.second_factor_enabled}}
|
||||||
<label>
|
<div class="instructions">
|
||||||
{{i18n "user.second_factor.short_description"}}
|
{{i18n "user.second_factor.short_description"}}
|
||||||
</label>
|
</div>
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
<div class="controls pref-second-factor">
|
<div class="controls pref-second-factor">
|
||||||
{{#if this.isCurrentUser}}
|
{{#if this.isCurrentUser}}
|
||||||
@ -45,7 +49,9 @@
|
|||||||
data-setting-name="user-auth-tokens"
|
data-setting-name="user-auth-tokens"
|
||||||
>
|
>
|
||||||
<label class="control-label">{{i18n "user.auth_tokens.title"}}</label>
|
<label class="control-label">{{i18n "user.auth_tokens.title"}}</label>
|
||||||
|
<div class="instructions">
|
||||||
|
{{i18n "user.auth_tokens.short_description"}}
|
||||||
|
</div>
|
||||||
<div class="auth-tokens">
|
<div class="auth-tokens">
|
||||||
{{#each this.authTokens as |token|}}
|
{{#each this.authTokens as |token|}}
|
||||||
<div class="row auth-token">
|
<div class="row auth-token">
|
||||||
|
@ -17,6 +17,10 @@ acceptance("User Preferences - Security", function (needs) {
|
|||||||
server.get("/u/eviltrout/activity.json", () => {
|
server.get("/u/eviltrout/activity.json", () => {
|
||||||
return helper.response({});
|
return helper.response({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.get("/u/trusted-session.json", () => {
|
||||||
|
return helper.response({ failed: "FAILED" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recently connected devices", async function (assert) {
|
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"
|
"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 {
|
.topic-statuses {
|
||||||
// avoid adding margin/padding on this parent; sometimes it appears as an empty container
|
// avoid adding margin/padding on this parent; sometimes it appears as an empty container
|
||||||
float: left;
|
float: left;
|
||||||
|
@ -790,3 +790,19 @@
|
|||||||
@extend .btn-flat;
|
@extend .btn-flat;
|
||||||
@extend .btn-small;
|
@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;
|
align-items: center;
|
||||||
gap: 1em;
|
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 {
|
.paginated-topics-list {
|
||||||
|
@ -180,7 +180,8 @@ input[type="submit"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
> input[type="text"],
|
> input[type="text"],
|
||||||
> input[type="search"] {
|
> input[type="search"],
|
||||||
|
> input[type="password"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@ -188,6 +189,7 @@ input[type="submit"] {
|
|||||||
> .select-kit,
|
> .select-kit,
|
||||||
> input[type="text"],
|
> input[type="text"],
|
||||||
> input[type="search"],
|
> input[type="search"],
|
||||||
|
> input[type="password"],
|
||||||
> label,
|
> label,
|
||||||
> .btn,
|
> .btn,
|
||||||
> .d-date-input {
|
> .d-date-input {
|
||||||
|
@ -168,6 +168,7 @@ class UserSerializer < UserCardSerializer
|
|||||||
def user_passkeys
|
def user_passkeys
|
||||||
UserSecurityKey
|
UserSecurityKey
|
||||||
.where(user_id: object.id, factor_type: UserSecurityKey.factor_types[:first_factor])
|
.where(user_id: object.id, factor_type: UserSecurityKey.factor_types[:first_factor])
|
||||||
|
.order("created_at ASC")
|
||||||
.map do |usk|
|
.map do |usk|
|
||||||
{ id: usk.id, name: usk.name, last_used: usk.last_used, created_at: usk.created_at }
|
{ id: usk.id, name: usk.name, last_used: usk.last_used, created_at: usk.created_at }
|
||||||
end
|
end
|
||||||
|
@ -1471,7 +1471,7 @@ en:
|
|||||||
disable_description: "Please enter the authentication code from your app"
|
disable_description: "Please enter the authentication code from your app"
|
||||||
show_key_description: "Enter manually"
|
show_key_description: "Enter manually"
|
||||||
short_description: |
|
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: |
|
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.
|
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."
|
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"
|
save: "Save"
|
||||||
edit_description: "Physical Security Key Name"
|
edit_description: "Physical Security Key Name"
|
||||||
name_required_error: "You must provide a name for your security key."
|
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:
|
change_about:
|
||||||
title: "Change About Me"
|
title: "Change About Me"
|
||||||
@ -1635,6 +1653,7 @@ en:
|
|||||||
|
|
||||||
auth_tokens:
|
auth_tokens:
|
||||||
title: "Recently Used Devices"
|
title: "Recently Used Devices"
|
||||||
|
short_description: "This is a list of devices that have recently logged into your account."
|
||||||
details: "Details"
|
details: "Details"
|
||||||
log_out_all: "Log out all"
|
log_out_all: "Log out all"
|
||||||
not_you: "Not you?"
|
not_you: "Not you?"
|
||||||
@ -1825,6 +1844,12 @@ en:
|
|||||||
success: "File uploaded successfully. You will be notified via message when the process is complete."
|
success: "File uploaded successfully. You will be notified via message when the process is complete."
|
||||||
error: "Sorry, file should be CSV format."
|
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:
|
password:
|
||||||
title: "Password"
|
title: "Password"
|
||||||
too_short: "Your password is too short."
|
too_short: "Your password is too short."
|
||||||
@ -1834,6 +1859,8 @@ en:
|
|||||||
ok: "Your password looks good."
|
ok: "Your password looks good."
|
||||||
instructions: "at least %{count} characters"
|
instructions: "at least %{count} characters"
|
||||||
required: "Please enter a password"
|
required: "Please enter a password"
|
||||||
|
confirm: "Confirm"
|
||||||
|
incorrect_password: "The entered password is incorrect."
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
title: "Summary"
|
title: "Summary"
|
||||||
@ -2233,6 +2260,8 @@ en:
|
|||||||
name: "Discord"
|
name: "Discord"
|
||||||
title: "Log in with Discord"
|
title: "Log in with Discord"
|
||||||
sr_title: "Log in with Discord"
|
sr_title: "Log in with Discord"
|
||||||
|
passkey:
|
||||||
|
name: "Login with a passkey"
|
||||||
second_factor_toggle:
|
second_factor_toggle:
|
||||||
totp: "Use an authenticator app instead"
|
totp: "Use an authenticator app instead"
|
||||||
backup_code: "Use a backup code instead"
|
backup_code: "Use a backup code instead"
|
||||||
@ -5145,7 +5174,7 @@ en:
|
|||||||
install: "Install"
|
install: "Install"
|
||||||
delete: "Delete"
|
delete: "Delete"
|
||||||
delete_confirm: 'Are you sure you want to delete "%{theme_name}"?'
|
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_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:"
|
bulk_components_delete_confirm: "This will uninstall the following components, they will no longer be useable by any users on your site:"
|
||||||
color: "Color"
|
color: "Color"
|
||||||
|
@ -442,7 +442,12 @@ RSpec.describe UserSerializer do
|
|||||||
|
|
||||||
context "with user_passkeys" do
|
context "with user_passkeys" do
|
||||||
fab!(:user) { Fabricate(:user) }
|
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
|
it "does not include them if feature is disabled" do
|
||||||
json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
|
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
|
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][:id]).to eq(passkey0.id)
|
||||||
expect(json[:user_passkeys][0][:name]).to eq(passkey.name)
|
expect(json[:user_passkeys][0][:name]).to eq(passkey0.name)
|
||||||
expect(json[:user_passkeys][0][:last_used]).to eq(passkey.last_used)
|
expect(json[:user_passkeys][0][:last_used]).to eq(passkey0.last_used)
|
||||||
|
expect(json[:user_passkeys][1][:id]).to eq(passkey1.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -10,16 +10,15 @@ describe "User preferences for Security", type: :system do
|
|||||||
before do
|
before do
|
||||||
user.activate
|
user.activate
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
|
|
||||||
|
# system specs run on their own host + port
|
||||||
|
DiscourseWebauthn.stubs(:origin).returns(current_host + ":" + Capybara.server_port.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Security keys" do
|
describe "Security keys" do
|
||||||
it "adds a 2F security key and logs in with it" do
|
it "adds a 2FA 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
|
|
||||||
options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new
|
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(user)
|
||||||
user_preferences_security_page.visit_second_factor(password)
|
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
|
find("#security-key .btn-primary").click
|
||||||
|
|
||||||
expect(page).to have_css(".header-dropdown-toggle.current-user")
|
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
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
x
Reference in New Issue
Block a user