mirror of
https://github.com/discourse/discourse.git
synced 2025-05-04 00:34:37 +08:00
DEV: Convert second-factor-add-security-key modal to component-based API (#22351)
This commit is contained in:
parent
773e198cb3
commit
a579bd6b28
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -11,9 +11,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
|||||||
import showModal from "discourse/lib/show-modal";
|
import showModal from "discourse/lib/show-modal";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase";
|
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, {
|
export default Controller.extend(CanCheckEmails, {
|
||||||
dialog: service(),
|
dialog: service(),
|
||||||
|
modal: service(),
|
||||||
loading: false,
|
loading: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
resetPasswordLoading: false,
|
resetPasswordLoading: false,
|
||||||
@ -42,6 +44,7 @@ export default Controller.extend(CanCheckEmails, {
|
|||||||
return user && user.enforcedSecondFactor;
|
return user && user.enforcedSecondFactor;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
handleError(error) {
|
handleError(error) {
|
||||||
if (error.jqXHR) {
|
if (error.jqXHR) {
|
||||||
error = error.jqXHR;
|
error = error.jqXHR;
|
||||||
@ -57,6 +60,7 @@ export default Controller.extend(CanCheckEmails, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
loadSecondFactors() {
|
loadSecondFactors() {
|
||||||
if (this.dirty === false) {
|
if (this.dirty === false) {
|
||||||
return;
|
return;
|
||||||
@ -89,6 +93,7 @@ export default Controller.extend(CanCheckEmails, {
|
|||||||
.finally(() => this.set("loading", false));
|
.finally(() => this.set("loading", false));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
markDirty() {
|
markDirty() {
|
||||||
this.set("dirty", true);
|
this.set("dirty", true);
|
||||||
},
|
},
|
||||||
@ -268,16 +273,15 @@ export default Controller.extend(CanCheckEmails, {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createSecurityKey() {
|
async createSecurityKey() {
|
||||||
const controller = showModal("second-factor-add-security-key", {
|
await this.modal.show(SecondFactorAddSecurityKey, {
|
||||||
model: this.model,
|
model: {
|
||||||
title: "user.second_factor.security_key.add",
|
secondFactor: this.model,
|
||||||
});
|
markDirty: this.markDirty,
|
||||||
controller.setProperties({
|
onError: this.handleError,
|
||||||
onClose: () => this.loadSecondFactors(),
|
},
|
||||||
markDirty: () => this.markDirty(),
|
|
||||||
onError: (e) => this.handleError(e),
|
|
||||||
});
|
});
|
||||||
|
this.loadSecondFactors();
|
||||||
},
|
},
|
||||||
|
|
||||||
editSecurityKey(security_key) {
|
editSecurityKey(security_key) {
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
@ -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>
|
|
Loading…
x
Reference in New Issue
Block a user