DEV: Convert second-factor-add-security-key modal to component-based API (#22351)

This commit is contained in:
Isaac Janzen 2023-07-04 10:15:37 -05:00 committed by GitHub
parent 773e198cb3
commit a579bd6b28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 211 additions and 207 deletions

View File

@ -0,0 +1,47 @@
<DModal
@closeModal={{@closeModal}}
@title={{i18n "user.second_factor.security_key.add"}}
{{did-insert this.securityKeyRequested}}
>
<:body>
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.errorMessage}}
<div class="control-group">
<div class="controls">
<div class="alert alert-error">{{this.errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group">
<div class="controls">
{{html-safe
(i18n "user.second_factor.enable_security_key_description")
}}
</div>
</div>
<div class="control-group">
<div class="controls">
<Input
@value={{this.securityKeyName}}
id="security-key-name"
placeholder="security key name"
/>
</div>
</div>
<div class="control-group">
<div class="controls">
{{#unless this.webauthnUnsupported}}
<DButton
class="btn-primary add-security-key"
@action={{this.registerSecurityKey}}
@label="user.second_factor.security_key.register"
/>
{{/unless}}
</div>
</div>
</ConditionalLoadingSpinner>
</:body>
</DModal>

View File

@ -0,0 +1,151 @@
import {
bufferToBase64,
isWebauthnSupported,
stringToBuffer,
} from "discourse/lib/webauthn";
import Component from "@glimmer/component";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class SecondFactorAddSecurityKey extends Component {
@service capabilities;
@tracked loading = false;
@tracked errorMessage = null;
@tracked securityKeyName;
get webauthnUnsupported() {
return !isWebauthnSupported();
}
@action
securityKeyRequested() {
let key;
if (this.capabilities.isIOS && !this.capabilities.isIpadOS) {
key = "user.second_factor.security_key.iphone_default_name";
} else if (this.capabilities.isAndroid) {
key = "user.second_factor.security_key.android_default_name";
} else {
key = "user.second_factor.security_key.default_name";
}
this.securityKeyName = key;
this.loading = true;
this.args.model.secondFactor
.requestSecurityKeyChallenge()
.then((response) => {
if (response.error) {
this.errorMessage = response.error;
return;
}
this.errorMessage = isWebauthnSupported()
? null
: I18n.t("login.security_key_support_missing_error");
this.loading = false;
this.challenge = response.challenge;
this.relayingParty = {
id: response.rp_id,
name: response.rp_name,
};
this.supported_algorithms = response.supported_algorithms;
this.user_secure_id = response.user_secure_id;
this.existing_active_credential_ids =
response.existing_active_credential_ids;
})
.catch((error) => {
this.args.closeModal();
this.args.model.onError(error);
})
.finally(() => (this.loading = false));
}
@action
registerSecurityKey() {
if (!this.securityKeyName) {
this.errorMessage = I18n.t(
"user.second_factor.security_key.name_required_error"
);
return;
}
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(this.challenge, (c) => c.charCodeAt(0)),
rp: {
name: this.relayingParty.name,
id: this.relayingParty.id,
},
user: {
id: Uint8Array.from(this.user_secure_id, (c) => c.charCodeAt(0)),
displayName: this.args.model.secondFactor.username_lower,
name: this.args.model.secondFactor.username_lower,
},
pubKeyCredParams: this.supported_algorithms.map((alg) => {
return { type: "public-key", alg };
}),
excludeCredentials: this.existing_active_credential_ids.map(
(credentialId) => {
return {
type: "public-key",
id: stringToBuffer(atob(credentialId)),
};
}
),
timeout: 20000,
attestation: "none",
authenticatorSelection: {
// 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
userVerification: "discouraged",
},
};
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.securityKeyName,
};
this.args.model.secondFactor
.registerSecurityKey(serverData)
.then((response) => {
if (response.error) {
this.errorMessage = response.error;
return;
}
this.args.model.markDirty();
this.errorMessage = null;
this.args.closeModal();
})
.catch((error) => this.args.model.onError(error))
.finally(() => (this.loading = false));
},
(err) => {
if (err.name === "InvalidStateError") {
this.errorMessage = I18n.t(
"user.second_factor.security_key.already_added_error"
);
return;
}
if (err.name === "NotAllowedError") {
this.errorMessage = I18n.t(
"user.second_factor.security_key.not_allowed_error"
);
return;
}
this.errorMessage = err.message;
}
);
}
}

View File

@ -11,9 +11,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase";
import SecondFactorAddSecurityKey from "discourse/components/modal/second-factor-add-security-key";
export default Controller.extend(CanCheckEmails, {
dialog: service(),
modal: service(),
loading: false,
dirty: false,
resetPasswordLoading: false,
@ -42,6 +44,7 @@ export default Controller.extend(CanCheckEmails, {
return user && user.enforcedSecondFactor;
},
@action
handleError(error) {
if (error.jqXHR) {
error = error.jqXHR;
@ -57,6 +60,7 @@ export default Controller.extend(CanCheckEmails, {
}
},
@action
loadSecondFactors() {
if (this.dirty === false) {
return;
@ -89,6 +93,7 @@ export default Controller.extend(CanCheckEmails, {
.finally(() => this.set("loading", false));
},
@action
markDirty() {
this.set("dirty", true);
},
@ -268,16 +273,15 @@ export default Controller.extend(CanCheckEmails, {
});
},
createSecurityKey() {
const controller = showModal("second-factor-add-security-key", {
model: this.model,
title: "user.second_factor.security_key.add",
});
controller.setProperties({
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: (e) => this.handleError(e),
async createSecurityKey() {
await this.modal.show(SecondFactorAddSecurityKey, {
model: {
secondFactor: this.model,
markDirty: this.markDirty,
onError: this.handleError,
},
});
this.loadSecondFactors();
},
editSecurityKey(security_key) {

View File

@ -1,157 +0,0 @@
import {
bufferToBase64,
isWebauthnSupported,
stringToBuffer,
} from "discourse/lib/webauthn";
import Controller from "@ember/controller";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
// model for this controller is user
export default Controller.extend(ModalFunctionality, {
loading: false,
errorMessage: null,
onShow() {
let securityKeyName;
if (this.capabilities.isIOS && !this.capabilities.isIpadOS) {
securityKeyName = I18n.t(
"user.second_factor.security_key.iphone_default_name"
);
} else if (this.capabilities.isAndroid) {
securityKeyName = I18n.t(
"user.second_factor.security_key.android_default_name"
);
} else {
securityKeyName = I18n.t("user.second_factor.security_key.default_name");
}
// clear properties every time because the controller is a singleton
this.setProperties({
errorMessage: null,
loading: true,
securityKeyName,
webauthnUnsupported: !isWebauthnSupported(),
});
this.model
.requestSecurityKeyChallenge()
.then((response) => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.setProperties({
errorMessage: isWebauthnSupported()
? null
: I18n.t("login.security_key_support_missing_error"),
loading: false,
challenge: response.challenge,
relayingParty: {
id: response.rp_id,
name: response.rp_name,
},
supported_algorithms: response.supported_algorithms,
user_secure_id: response.user_secure_id,
existing_active_credential_ids:
response.existing_active_credential_ids,
});
})
.catch((error) => {
this.send("closeModal");
this.onError(error);
})
.finally(() => this.set("loading", false));
},
actions: {
registerSecurityKey() {
if (!this.securityKeyName) {
this.set(
"errorMessage",
I18n.t("user.second_factor.security_key.name_required_error")
);
return;
}
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(this.challenge, (c) => c.charCodeAt(0)),
rp: {
name: this.relayingParty.name,
id: this.relayingParty.id,
},
user: {
id: Uint8Array.from(this.user_secure_id, (c) => c.charCodeAt(0)),
displayName: this.model.username_lower,
name: this.model.username_lower,
},
pubKeyCredParams: this.supported_algorithms.map((alg) => {
return { type: "public-key", alg };
}),
excludeCredentials: this.existing_active_credential_ids.map(
(credentialId) => {
return {
type: "public-key",
id: stringToBuffer(atob(credentialId)),
};
}
),
timeout: 20000,
attestation: "none",
authenticatorSelection: {
// 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
userVerification: "discouraged",
},
};
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.securityKeyName,
};
this.model
.registerSecurityKey(serverData)
.then((response) => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.markDirty();
this.set("errorMessage", null);
this.send("closeModal");
})
.catch((error) => this.onError(error))
.finally(() => this.set("loading", false));
},
(err) => {
if (err.name === "InvalidStateError") {
return this.set(
"errorMessage",
I18n.t("user.second_factor.security_key.already_added_error")
);
}
if (err.name === "NotAllowedError") {
return this.set(
"errorMessage",
I18n.t("user.second_factor.security_key.not_allowed_error")
);
}
this.set("errorMessage", err.message);
}
);
},
},
});

View File

@ -1,41 +0,0 @@
<DModalBody>
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.errorMessage}}
<div class="control-group">
<div class="controls">
<div class="alert alert-error">{{this.errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group">
<div class="controls">
{{html-safe
(i18n "user.second_factor.enable_security_key_description")
}}
</div>
</div>
<div class="control-group">
<div class="controls">
<Input
@value={{this.securityKeyName}}
id="security-key-name"
placeholder="security key name"
/>
</div>
</div>
<div class="control-group">
<div class="controls">
{{#unless this.webauthnUnsupported}}
<DButton
@class="btn-primary add-security-key"
@action={{action "registerSecurityKey"}}
@label="user.second_factor.security_key.register"
/>
{{/unless}}
</div>
</div>
</ConditionalLoadingSpinner>
</DModalBody>