Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEV: Add UI for passkeys (3/3) #23591

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@
{{b.title}}
</button>
{{/each}}

{{#if this.canUsePasskeys}}
<PasskeyLoginButton />
{{/if}}

<PluginOutlet @name="after-login-buttons" />
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import { findAll } from "discourse/models/login-method";
import { isWebauthnSupported } from "discourse/lib/webauthn";

export default Component.extend({
elementId: "login-buttons",
Expand All @@ -16,6 +17,11 @@ export default Component.extend({
return findAll();
},

@discourseComputed
canUsePasskeys() {
return this.siteSettings.experimental_passkeys && isWebauthnSupported();
},

actions: {
externalLogin(provider) {
this.externalLogin(provider);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<DButton
@action={{this.passkeyLogin}}
@icon="user"
@label="login.passkey.name"
class="btn btn-social passkey-login-button"
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { prepPasskeyCredential } from "discourse/lib/webauthn";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default class PasskeyLoginButton extends Component {
@service dialog;
tagName = "";

@action
passkeyLogin() {
ajax("/session/passkey/challenge.json")
.then((response) => {
prepPasskeyCredential(response.challenge, (errorMessage) => {
this.dialog.alert(errorMessage);
}).then((credential) => {
ajax("/session/passkey/auth.json", {
type: "POST",
data: {
publicKeyCredential: credential,
timezone: moment.tz.guess(),
},
})
.then((result) => {
if (result && !result.error) {
// TODO(pmusaraj): See if this is necessary
window.location.reload();
} else {
this.dialog.alert(result.error);
}
})
.catch(popupAjaxError);
});
})
.catch(popupAjaxError);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
import I18n from "I18n";
import { computed } from "@ember/object";

export default DropdownSelectBoxComponent.extend({
classNames: ["token-based-auth-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"),
},
];
}),

actions: {
onChange(id) {
switch (id) {
case "edit":
this.renamePasskey(this.passkeyId);
break;
case "delete":
this.deletePasskey(this.passkeyId);
break;
}
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="rename-passkey__form">
<div class="rename-passkey__message">
<p>{{i18n "user.passkeys.rename_passkey_instructions"}}</p>
</div>
<form>
<div class="rename-passkey__form inline-form">
<TextField @value={{this.passkeyName}} autofocus={{true}} />
<DButton
@class="btn-primary"
@type="submit"
@action={{this.saveRename}}
@label="user.passkeys.save"
/>
</div>
</form>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { tracked } from "@glimmer/tracking";

export default class RenamePasskey extends Component {
@tracked passkeyName;

constructor() {
super(...arguments);
this.passkeyName = this.args.model.name;
}

@action
saveRename() {
ajax(`/u/rename_passkey/${this.args.model.id}`, {
type: "POST",
data: {
name: this.passkeyName,
},
}).then(() => {
// TODO(pmusaraj): replace this with a model refresh
// it's a little tricky, we're using the same dialog
// for both adding and renaming passkeys
window.location.reload();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<div class="row pref-passkeys">
<div class="control-group">
<label class="control-label">
{{i18n "user.passkeys.title"}}
</label>
{{#each @model.user_passkeys as |passkey|}}
<div class="row">
<div class="passkey-left">
<div><b>{{passkey.name}}</b></div>
<div class="row-passkey__created-date">
<span class="prefix">
{{i18n "user.passkeys.added_prefix"}}
</span>
{{format-date passkey.created_at format="medium" leaveAgo="true"}}
</div>
<div class="row-passkey__used-date">
{{#if passkey.last_used}}
<span class="prefix">
{{i18n "user.passkeys.last_used_prefix"}}
</span>
{{format-date passkey.last_used format="medium" leaveAgo="true"}}
{{else}}
{{i18n "user.passkeys.never_used"}}
{{/if}}
</div>
</div>
{{#if (eq this.currentUser.id @model.id)}}
<div class="passkey-right">
<div class="actions">
<UserPreferences::PasskeyOptionsDropdown
@passkeyId={{passkey.id}}
@deletePasskey={{action "deletePasskey" passkey.id}}
@renamePasskey={{action
"renamePasskey"
passkey.id
passkey.name
}}
/>
</div>
</div>
{{/if}}
</div>
{{/each}}
</div>

{{this.passkeyName}}

{{#if (eq this.currentUser.id @model.id)}}
<DButton
@action={{action "addPasskey"}}
@icon="plus"
@label="user.passkeys.add_passkey"
/>
{{/if}}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import I18n from "I18n";
import { bufferToBase64, stringToBuffer } from "discourse/lib/webauthn";
import RenamePasskey from "discourse/components/user-preferences/rename-passkey";
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";

export default class UserPasskeys extends Component {
@service dialog;
@service currentUser;
@service capabilities;

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

@action
addPasskey() {
this.args.model.createPasskey().then((response) => {
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",
},
};

navigator.credentials
.create({
publicKey: publicKeyCredentialCreationOptions,
})
.then(
(credential) => {
let serverData = {
id: credential.id,
rawId: bufferToBase64(credential.rawId),
type: credential.type,
attestation: bufferToBase64(
credential.response.attestationObject
),
clientData: bufferToBase64(credential.response.clientDataJSON),
name: this.passkeyDefaultName(),
};

this.args.model
.registerPasskey(serverData)
.then((resp) => {
if (resp.error) {
this.dialog.alert(resp.error);
return;
}

// Show rename alert after creating/saving new key
this.dialog.dialog({
title: I18n.t("user.passkeys.passkey_successfully_created"),
type: "notice",
bodyComponent: RenamePasskey,
bodyComponentModel: resp,
didCancel: () => {
// TODO(pmusaraj): avoid refreshing the page
window.location.reload();
},
});
})
.catch((res) => {
this.dialog.alert(res.error);
});
},
(err) => {
if (err.name === "InvalidStateError") {
this.errorMessage = I18n.t(
"user.second_factor.security_key.already_added_error"
);
}
if (err.name === "NotAllowedError") {
this.errorMessage = I18n.t(
"user.second_factor.security_key.not_allowed_error"
);
}
this.dialog.alert(this.errorMessage);
}
);
});
}

@action
deletePasskey(id) {
this.dialog.deleteConfirm({
title: I18n.t("user.passkeys.confirm_delete_passkey"),
didConfirm: () => {
this.args.model.deletePasskey(id).then(() => {
window.location.reload();
});
},
});
}

@action
renamePasskey(id, name) {
this.dialog.dialog({
title: I18n.t("user.passkeys.rename_passkey"),
type: "notice",
bodyComponent: RenamePasskey,
bodyComponentModel: { id, name },
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CanCheckEmails from "discourse/mixins/can-check-emails";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import AuthTokenModal from "discourse/components/modal/auth-token";
import { isWebauthnSupported } from "discourse/lib/webauthn";

// Number of tokens shown by default.
const DEFAULT_AUTH_TOKENS_COUNT = 2;
Expand All @@ -20,6 +21,10 @@ export default Controller.extend(CanCheckEmails, {
subpageTitle: I18n.t("user.preferences_nav.security"),
showAllAuthTokens: false,

canUsePasskeys() {
return this.siteSettings.experimental_passkeys && isWebauthnSupported();
},

@discourseComputed("model.is_anonymous")
canChangePassword(isAnonymous) {
if (isAnonymous) {
Expand Down