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:
Penar Musaraj 2023-10-13 12:24:06 -04:00 committed by GitHub
parent a5858e60e1
commit 1a70817962
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 889 additions and 74 deletions

View File

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

View File

@ -15,4 +15,9 @@
{{b.title}}
</button>
{{/each}}
{{#if this.canUsePasskeys}}
<PasskeyLoginButton />
{{/if}}
<PluginOutlet @name="after-login-buttons" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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