FEATURE: add ability to have multiple totp factors (#7626)

Adds a second factor landing page that centralizes a user's second factor configuration.

This contains both TOTP and Backup, and also allows multiple TOTP tokens to be registered and organized by a name. Access to this page is authenticated via password, and cached for 30 minutes via a secure session.
This commit is contained in:
Jeff Wong 2019-06-26 16:58:06 -07:00 committed by GitHub
parent b2a033e92b
commit 88ef5e55fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 793 additions and 549 deletions

View File

@ -1,37 +1,28 @@
import { default as computed } from "ember-addons/ember-computed-decorators"; import { default as computed } from "ember-addons/ember-computed-decorators";
import CanCheckEmails from "discourse/mixins/can-check-emails";
import { default as DiscourseURL, userPath } from "discourse/lib/url"; import { default as DiscourseURL, userPath } from "discourse/lib/url";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
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 showModal from "discourse/lib/show-modal";
export default Ember.Controller.extend({ export default Ember.Controller.extend(CanCheckEmails, {
loading: false, loading: false,
dirty: false,
resetPasswordLoading: false, resetPasswordLoading: false,
resetPasswordProgress: "", resetPasswordProgress: "",
password: null, password: null,
secondFactorImage: null,
secondFactorKey: null,
showSecondFactorKey: false,
errorMessage: null, errorMessage: null,
newUsername: null, newUsername: null,
backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"), backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
totps: null,
loaded: Ember.computed.and("secondFactorImage", "secondFactorKey"), loaded: Ember.computed.and("secondFactorImage", "secondFactorKey"),
@computed("loading") init() {
submitButtonText(loading) { this._super(...arguments);
return loading ? "loading" : "continue"; this.set("totps", []);
},
@computed("loading")
enableButtonText(loading) {
return loading ? "loading" : "enable";
},
@computed("loading")
disableButtonText(loading) {
return loading ? "loading" : "disable";
}, },
@computed @computed
@ -41,58 +32,64 @@ export default Ember.Controller.extend({
@computed("currentUser") @computed("currentUser")
showEnforcedNotice(user) { showEnforcedNotice(user) {
return user && user.get("enforcedSecondFactor"); return user && user.enforcedSecondFactor;
}, },
toggleSecondFactor(enable) { handleError(error) {
if (!this.secondFactorToken) return; if (error.jqXHR) {
error = error.jqXHR;
}
let parsedJSON = error.responseJSON;
if (parsedJSON.error_type === "invalid_access") {
const usernameLower = this.model.username.toLowerCase();
DiscourseURL.redirectTo(
userPath(`${usernameLower}/preferences/second-factor`)
);
} else {
popupAjaxError(error);
}
},
loadSecondFactors() {
if (this.dirty === false) {
return;
}
this.set("loading", true); this.set("loading", true);
this.model this.model
.toggleSecondFactor( .loadSecondFactorCodes(this.password)
this.secondFactorToken,
this.secondFactorMethod,
SECOND_FACTOR_METHODS.TOTP,
enable
)
.then(response => { .then(response => {
if (response.error) { if (response.error) {
this.set("errorMessage", response.error); this.set("errorMessage", response.error);
return; return;
} }
this.set("errorMessage", null); this.setProperties({
DiscourseURL.redirectTo( errorMessage: null,
userPath(`${this.model.username.toLowerCase()}/preferences`) loaded: true,
totps: response.totps,
password: null,
dirty: false
});
this.set(
"model.second_factor_enabled",
response.totps && response.totps.length > 0
); );
}) })
.catch(error => { .catch(e => this.handleError(e))
popupAjaxError(error);
})
.finally(() => this.set("loading", false)); .finally(() => this.set("loading", false));
}, },
markDirty() {
this.set("dirty", true);
},
actions: { actions: {
confirmPassword() { confirmPassword() {
if (!this.password) return; if (!this.password) return;
this.set("loading", true); this.markDirty();
this.loadSecondFactors();
this.model this.set("password", null);
.loadSecondFactorCodes(this.password)
.then(response => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.setProperties({
errorMessage: null,
secondFactorKey: response.key,
secondFactorImage: response.qr
});
})
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
}, },
resetPassword() { resetPassword() {
@ -113,16 +110,66 @@ export default Ember.Controller.extend({
.finally(() => this.set("resetPasswordLoading", false)); .finally(() => this.set("resetPasswordLoading", false));
}, },
showSecondFactorKey() { disableAllSecondFactors() {
this.set("showSecondFactorKey", true); if (this.loading) {
return;
}
bootbox.confirm(
I18n.t("user.second_factor.disable_confirm"),
I18n.t("cancel"),
I18n.t("user.second_factor.disable"),
result => {
if (result) {
this.model
.disableAllSecondFactors()
.then(() => {
const usernameLower = this.model.username.toLowerCase();
DiscourseURL.redirectTo(
userPath(`${usernameLower}/preferences`)
);
})
.catch(e => this.handleError(e))
.finally(() => this.set("loading", false));
}
}
);
}, },
enableSecondFactor() { createTotp() {
this.toggleSecondFactor(true); const controller = showModal("second-factor-add-totp", {
model: this.model,
title: "user.second_factor.totp.add"
});
controller.setProperties({
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: e => this.handleError(e)
});
}, },
disableSecondFactor() { editSecondFactor(second_factor) {
this.toggleSecondFactor(false); const controller = showModal("second-factor-edit", {
model: second_factor,
title: "user.second_factor.edit_title"
});
controller.setProperties({
user: this.model,
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: e => this.handleError(e)
});
},
editSecondFactorBackup() {
const controller = showModal("second-factor-backup-edit", {
model: this.model,
title: "user.second_factor_backup.title"
});
controller.setProperties({
onClose: () => this.loadSecondFactors(),
markDirty: () => this.markDirty(),
onError: e => this.handleError(e)
});
} }
} }
}); });

View File

@ -0,0 +1,67 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
loading: false,
secondFactorImage: null,
secondFactorKey: null,
showSecondFactorKey: false,
errorMessage: null,
onShow() {
this.setProperties({
errorMessage: null,
secondFactorKey: null,
secondFactorToken: null,
showSecondFactorKey: false,
secondFactorImage: null,
loading: true
});
this.model
.createSecondFactorTotp()
.then(response => {
if (response.error) {
this.set("errorMessage", response.error);
return;
}
this.setProperties({
errorMessage: null,
secondFactorKey: response.key,
secondFactorImage: response.qr
});
})
.catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => this.set("loading", false));
},
actions: {
showSecondFactorKey() {
this.set("showSecondFactorKey", true);
},
enableSecondFactor() {
if (!this.secondFactorToken) return;
this.set("loading", true);
this.model
.enableSecondFactorTotp(
this.secondFactorToken,
I18n.t("user.second_factor.totp.default_name")
)
.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));
}
}
});

View File

@ -1,9 +1,8 @@
import { default as computed } from "ember-addons/ember-computed-decorators"; import { default as computed } from "ember-addons/ember-computed-decorators";
import { default as DiscourseURL, userPath } from "discourse/lib/url";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend({ export default Ember.Controller.extend(ModalFunctionality, {
loading: false, loading: false,
errorMessage: null, errorMessage: null,
successMessage: null, successMessage: null,
@ -14,25 +13,6 @@ export default Ember.Controller.extend({
backupCodes: null, backupCodes: null,
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
@computed("secondFactorToken", "secondFactorMethod")
isValidSecondFactorToken(secondFactorToken, secondFactorMethod) {
if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) {
return secondFactorToken && secondFactorToken.length === 6;
} else if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) {
return secondFactorToken && secondFactorToken.length === 16;
}
},
@computed("isValidSecondFactorToken", "backupEnabled", "loading")
isDisabledGenerateBackupCodeBtn(isValid, backupEnabled, loading) {
return !isValid || loading;
},
@computed("isValidSecondFactorToken", "backupEnabled", "loading")
isDisabledDisableBackupCodeBtn(isValid, backupEnabled, loading) {
return !isValid || !backupEnabled || loading;
},
@computed("backupEnabled") @computed("backupEnabled")
generateBackupCodeBtnLabel(backupEnabled) { generateBackupCodeBtnLabel(backupEnabled) {
return backupEnabled return backupEnabled
@ -40,6 +20,15 @@ export default Ember.Controller.extend({
: "user.second_factor_backup.enable"; : "user.second_factor_backup.enable";
}, },
onShow() {
this.setProperties({
loading: false,
errorMessage: null,
successMessage: null,
backupCodes: null
});
},
actions: { actions: {
copyBackupCode(successful) { copyBackupCode(successful) {
if (successful) { if (successful) {
@ -59,18 +48,10 @@ export default Ember.Controller.extend({
disableSecondFactorBackup() { disableSecondFactorBackup() {
this.set("backupCodes", []); this.set("backupCodes", []);
if (!this.secondFactorToken) return;
this.set("loading", true); this.set("loading", true);
this.model this.model
.toggleSecondFactor( .updateSecondFactor(0, "", true, SECOND_FACTOR_METHODS.BACKUP_CODE)
this.secondFactorToken,
this.secondFactorMethod,
SECOND_FACTOR_METHODS.BACKUP_CODE,
false
)
.then(response => { .then(response => {
if (response.error) { if (response.error) {
this.set("errorMessage", response.error); this.set("errorMessage", response.error);
@ -78,28 +59,28 @@ export default Ember.Controller.extend({
} }
this.set("errorMessage", null); this.set("errorMessage", null);
this.model.set("second_factor_backup_enabled", false);
const usernameLower = this.model.username.toLowerCase(); this.markDirty();
DiscourseURL.redirectTo(userPath(`${usernameLower}/preferences`)); this.send("closeModal");
})
.catch(error => {
this.send("closeModal");
this.onError(error);
}) })
.catch(popupAjaxError)
.finally(() => this.set("loading", false)); .finally(() => this.set("loading", false));
}, },
generateSecondFactorCodes() { generateSecondFactorCodes() {
if (!this.secondFactorToken) return;
this.set("loading", true); this.set("loading", true);
this.model this.model
.generateSecondFactorCodes( .generateSecondFactorCodes()
this.secondFactorToken,
this.secondFactorMethod
)
.then(response => { .then(response => {
if (response.error) { if (response.error) {
this.set("errorMessage", response.error); this.set("errorMessage", response.error);
return; return;
} }
this.markDirty();
this.setProperties({ this.setProperties({
errorMessage: null, errorMessage: null,
backupCodes: response.backup_codes, backupCodes: response.backup_codes,
@ -107,11 +88,13 @@ export default Ember.Controller.extend({
remainingCodes: response.backup_codes.length remainingCodes: response.backup_codes.length
}); });
}) })
.catch(popupAjaxError) .catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => { .finally(() => {
this.setProperties({ this.setProperties({
loading: false, loading: false
secondFactorToken: null
}); });
}); });
} }

View File

@ -0,0 +1,53 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
actions: {
disableSecondFactor() {
this.user
.updateSecondFactor(
this.model.id,
this.model.name,
true,
this.model.method
)
.then(response => {
if (response.error) {
return;
}
this.markDirty();
})
.catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => {
this.set("loading", false);
this.send("closeModal");
});
},
editSecondFactor() {
this.user
.updateSecondFactor(
this.model.id,
this.model.name,
false,
this.model.method
)
.then(response => {
if (response.error) {
return;
}
this.markDirty();
})
.catch(error => {
this.send("closeModal");
this.onError(error);
})
.finally(() => {
this.set("loading", false);
this.send("closeModal");
});
}
}
});

View File

@ -205,17 +205,13 @@ const User = RestModel.extend({
return suspendedTill && moment(suspendedTill).isAfter(); return suspendedTill && moment(suspendedTill).isAfter();
}, },
@computed("suspended_till") @computed("suspended_till") suspendedForever: isForever,
suspendedForever: isForever,
@computed("silenced_till") @computed("silenced_till") silencedForever: isForever,
silencedForever: isForever,
@computed("suspended_till") @computed("suspended_till") suspendedTillDate: longDate,
suspendedTillDate: longDate,
@computed("silenced_till") @computed("silenced_till") silencedTillDate: longDate,
silencedTillDate: longDate,
changeUsername(new_username) { changeUsername(new_username) {
return ajax(userPath(`${this.username_lower}/preferences/username`), { return ajax(userPath(`${this.username_lower}/preferences/username`), {
@ -366,6 +362,40 @@ const User = RestModel.extend({
}); });
}, },
createSecondFactorTotp() {
return ajax("/u/create_second_factor_totp.json", {
type: "POST"
});
},
enableSecondFactorTotp(authToken, name) {
return ajax("/u/enable_second_factor_totp.json", {
data: {
second_factor_token: authToken,
name
},
type: "POST"
});
},
disableAllSecondFactors() {
return ajax("/u/disable_second_factor.json", {
type: "PUT"
});
},
updateSecondFactor(id, name, disable, targetMethod) {
return ajax("/u/second_factor.json", {
data: {
second_factor_target: targetMethod,
name,
disable,
id
},
type: "PUT"
});
},
toggleSecondFactor(authToken, authMethod, targetMethod, enable) { toggleSecondFactor(authToken, authMethod, targetMethod, enable) {
return ajax("/u/second_factor.json", { return ajax("/u/second_factor.json", {
data: { data: {
@ -378,12 +408,8 @@ const User = RestModel.extend({
}); });
}, },
generateSecondFactorCodes(authToken, authMethod) { generateSecondFactorCodes() {
return ajax("/u/second_factors_backup.json", { return ajax("/u/second_factors_backup.json", {
data: {
second_factor_token: authToken,
second_factor_method: authMethod
},
type: "PUT" type: "PUT"
}); });
}, },

View File

@ -1,21 +0,0 @@
import RestrictedUserRoute from "discourse/routes/restricted-user";
export default RestrictedUserRoute.extend({
showFooter: true,
model() {
return this.modelFor("user");
},
renderTemplate() {
return this.render({ into: "user" });
},
setupController(controller, model) {
controller.setProperties({ model, newUsername: model.get("username") });
},
deactivate() {
this.controller.setProperties({ backupCodes: null });
}
});

View File

@ -13,6 +13,23 @@ export default RestrictedUserRoute.extend({
setupController(controller, model) { setupController(controller, model) {
controller.setProperties({ model, newUsername: model.get("username") }); controller.setProperties({ model, newUsername: model.get("username") });
controller.set("loading", true);
model
.loadSecondFactorCodes("")
.then(response => {
if (response.error) {
controller.set("errorMessage", response.error);
} else {
controller.setProperties({
errorMessage: null,
loaded: !response.password_required,
dirty: !!response.password_required,
totps: response.totps
});
}
})
.catch(controller.popupAjaxError)
.finally(() => controller.set("loading", false));
}, },
actions: { actions: {

View File

@ -0,0 +1,51 @@
{{#d-modal-body}}
{{#conditional-loading-spinner condition=loading}}
{{#if errorMessage}}
<div class="control-group">
<div class="controls">
<div class='alert alert-error'>{{errorMessage}}</div>
</div>
</div>
{{/if}}
<div class="control-group">
<div class="controls">
{{{i18n 'user.second_factor.enable_description'}}}
</div>
</div>
<div class="control-group">
<div class="controls">
<div class="qr-code-container">
<div class="qr-code">
{{{secondFactorImage}}}
</div>
</div>
<p>
{{#if showSecondFactorKey}}
{{secondFactorKey}}
{{else}}
<a {{action "showSecondFactorKey"}}>{{i18n 'user.second_factor.show_key_description'}}</a>
{{/if}}
</p>
</div>
</div>
<div class="control-group">
<label class="control-label input-prepend">{{i18n 'user.second_factor.label'}}</label>
<div class="controls">
{{second-factor-input maxlength=6 value=secondFactorToken inputId='second-factor-token'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action=(action "enableSecondFactor")
class="btn btn-primary add-totp"
label="enable"}}
</div>
</div>
{{/conditional-loading-spinner}}
{{/d-modal-body}}

View File

@ -0,0 +1,50 @@
{{#d-modal-body}}
<section class="user-preferences solo-preference second-factor-backup-preferences">
<form class="form-horizontal">
{{#if successMessage}}
<div class="alert alert-success">
{{successMessage}}
</div>
{{/if}}
{{#if errorMessage}}
<div class="alert alert-error">
{{errorMessage}}
</div>
{{/if}}
{{#if backupEnabled}}
{{{i18n "user.second_factor_backup.remaining_codes" count=remainingCodes}}}
{{/if}}
<div class="actions">
{{d-button
action=(action "generateSecondFactorCodes")
class="btn btn-primary"
disabled=loading
label=generateBackupCodeBtnLabel}}
{{#if backupEnabled}}
{{d-button
action=(action "disableSecondFactorBackup")
class="btn btn-danger"
disabled=loading
label="user.second_factor_backup.disable"}}
{{/if}}
</div>
{{#conditional-loading-section isLoading=loading}}
{{#if backupCodes}}
<h3>{{i18n "user.second_factor_backup.codes.title"}}</h3>
<p>
{{i18n "user.second_factor_backup.codes.description"}}
</p>
{{backup-codes
copyBackupCode=(action "copyBackupCode")
backupCodes=backupCodes}}
{{/if}}
{{/conditional-loading-section}}
</form>
</section>
{{/d-modal-body}}

View File

@ -0,0 +1,15 @@
{{#d-modal-body}}
<div class="form-horizontal">
{{input type="text" value=model.name}}
</div>
<div class='second-factor instructions'>
{{i18n 'user.second_factor.edit_description'}}
</div>
{{d-button action=(action "editSecondFactor")
class="btn-primary"
label="user.second_factor.edit"}}
{{d-button action=(action "disableSecondFactor")
class="btn-danger"
label="user.second_factor.disable"}}
{{/d-modal-body}}

View File

@ -1,71 +0,0 @@
<section class="user-preferences solo-preference second-factor-backup-preferences">
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
{{#if successMessage}}
<div class="alert alert-success">
{{successMessage}}
</div>
{{/if}}
{{#if errorMessage}}
<div class="alert alert-error">
{{errorMessage}}
</div>
{{/if}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{#second-factor-form
secondFactorMethod=secondFactorMethod
backupEnabled=backupEnabled
secondFactorToken=secondFactorToken
secondFactorTitle=(i18n 'user.second_factor_backup.title')
optionalText=(if backupEnabled (i18n "user.second_factor_backup.remaining_codes" count=remainingCodes))
isLogin=false}}
{{second-factor-input value=secondFactorToken inputId='second-factor-token' secondFactorMethod=secondFactorMethod}}
{{/second-factor-form}}
</div>
</div>
<div class="control-group">
<div class="controls">
<div class="actions">
{{d-button
action=(action "generateSecondFactorCodes")
class="btn btn-primary"
disabled=isDisabledGenerateBackupCodeBtn
label=generateBackupCodeBtnLabel}}
{{#if backupEnabled}}
{{d-button
action=(action "disableSecondFactorBackup")
class="btn btn-danger"
disabled=isDisabledDisableBackupCodeBtn
label="user.second_factor_backup.disable"}}
{{/if}}
</div>
{{#conditional-loading-section isLoading=loading}}
{{#if backupCodes}}
<h3>{{i18n "user.second_factor_backup.codes.title"}}</h3>
<p>
{{i18n "user.second_factor_backup.codes.description"}}
</p>
{{backup-codes
copyBackupCode=(action "copyBackupCode")
backupCodes=backupCodes}}
{{#link-to "preferences.account" model.username}}
{{i18n "go_back"}}
{{/link-to}}
{{/if}}
{{/conditional-loading-section}}
</div>
</div>
</form>
</section>

View File

@ -1,4 +1,5 @@
<section class='user-preferences solo-preference'> <section class='user-preferences solo-preference second-factor'>
{{#conditional-loading-spinner condition=loading}}
<form class="form-horizontal"> <form class="form-horizontal">
{{#if showEnforcedNotice}} {{#if showEnforcedNotice}}
@ -9,6 +10,14 @@
</div> </div>
{{/if}} {{/if}}
{{#if displayOAuthWarning}}
<div class="control-group">
<div class="controls">
{{i18n 'user.second_factor.oauth_enabled_warning'}}
</div>
</div>
{{/if}}
{{#if errorMessage}} {{#if errorMessage}}
<div class="control-group"> <div class="control-group">
<div class="controls"> <div class="controls">
@ -17,121 +26,105 @@
</div> </div>
{{/if}} {{/if}}
{{#if model.second_factor_enabled}} {{#if loaded}}
<div class="control-group"> <div class="control-group">
<div class="controls"> <div class="controls">
{{#second-factor-form <h2>{{i18n "user.second_factor.totp.title"}}</h2>
secondFactorMethod=secondFactorMethod {{d-button action=(action "createTotp")
backupEnabled=backupEnabled class="btn-primary new-totp"
secondFactorToken=secondFactorToken disabled=loading
secondFactorTitle=(i18n 'user.second_factor.title') label="user.second_factor.totp.add"}}
isLogin=false}} {{#each totps as |totp|}}
{{second-factor-input value=secondFactorToken inputId='second-factor-token' secondFactorMethod=secondFactorMethod}} <div class="second-factor-item">
{{/second-factor-form}} {{totp.name}}
{{#if isCurrentUser}}
{{d-button action=(action "editSecondFactor" totp)
class="btn-default btn-small btn-icon pad-left no-text edit"
disabled=loading
icon="pencil-alt"
}}
{{/if}}
</div>
{{/each}}
</div>
</div>
<div class="control-group">
<div class="controls pref-second-factor-backup">
<h2>{{i18n "user.second_factor_backup.title"}}</h2>
{{#if model.second_factor_enabled}}
{{#if model.second_factor_backup_enabled}}
{{{i18n 'user.second_factor_backup.manage' count=model.second_factor_remaining_backup_codes}}}
{{else}}
{{i18n 'user.second_factor_backup.enable_long'}}
{{/if}}
{{#if isCurrentUser}}
{{d-button action=(action "editSecondFactorBackup")
class="btn-default btn-small btn-icon pad-left no-text edit edit-2fa-backup"
disabled=loading
icon="pencil-alt"
}}
{{/if}}
{{else}}
{{i18n "user.second_factor_backup.enable_prerequisites"}}
{{/if}}
</div>
</div>
{{#if model.second_factor_enabled}}
{{#unless showEnforcedNotice}}
<div class="control-group">
<div class="controls">
<h2>{{i18n "user.second_factor.disable_title"}}</h2>
{{d-button action=(action "disableAllSecondFactors")
class="btn btn-danger"
disabled=loading
label="disable"}}
</div>
</div>
{{/unless}}
{{/if}}
{{else}}
<div class="control-group">
<label class='control-label'>{{i18n 'user.password.title'}}</label>
<div class="controls">
<div>
{{text-field value=password
id="password"
type="password"
classNames="input-xxlarge"
autofocus="autofocus"}}
</div>
<div class='instructions'>
{{i18n 'user.second_factor.confirm_password_description'}}
</div>
</div> </div>
</div> </div>
<div class="control-group"> <div class="control-group">
<div class="controls"> <div class="controls">
{{d-button action=(action "disableSecondFactor") {{d-button action=(action "confirmPassword")
class="btn btn-primary" class="btn-primary"
disabled=loading disabled=loading
label=disableButtonText}} label="continue"}}
{{d-button action=(action "resetPassword")
class="btn"
disabled=resetPasswordLoading
icon="envelope"
label='user.change_password.action'}}
{{resetPasswordProgress}}
{{#unless showEnforcedNotice}} {{#unless showEnforcedNotice}}
{{cancel-link route="preferences.account" args= model.username}} {{cancel-link route="preferences.account" args= model.username}}
{{/unless}} {{/unless}}
</div> </div>
</div> </div>
{{else}}
{{#if loaded}}
<div class="control-group">
<div class="controls">
{{{i18n 'user.second_factor.enable_description'}}}
{{#if displayOAuthWarning}}
{{i18n 'user.second_factor.oauth_enabled_warning'}}
{{/if}}
</div>
</div>
<div class="control-group">
<div class="controls">
<div class="qr-code-container">
<div class="qr-code">
{{{secondFactorImage}}}
</div>
</div>
<p>
{{#if showSecondFactorKey}}
{{secondFactorKey}}
{{else}}
<a {{action "showSecondFactorKey"}}>{{i18n 'user.second_factor.show_key_description'}}</a>
{{/if}}
</p>
</div>
</div>
<div class="control-group">
<label class="control-label input-prepend">{{i18n 'user.second_factor.label'}}</label>
<div class="controls">
{{second-factor-input maxlength=6 value=secondFactorToken inputId='second-factor-token'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action=(action "enableSecondFactor")
class="btn btn-primary"
disabled=loading
label=enableButtonText}}
{{#unless showEnforcedNotice}}
{{cancel-link route="preferences.account" args= model.username}}
{{/unless}}
</div>
</div>
{{else}}
<div class="control-group">
<label class='control-label'>{{i18n 'user.password.title'}}</label>
<div class="controls">
<div>
{{text-field value=password
id="password"
type="password"
classNames="input-xxlarge"
autofocus="autofocus"}}
</div>
<div class='instructions'>
{{i18n 'user.second_factor.confirm_password_description'}}
</div>
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action=(action "confirmPassword")
class="btn btn-primary"
disabled=loading
label=submitButtonText}}
{{d-button action=(action "resetPassword")
class="btn"
disabled=resetPasswordLoading
icon="envelope"
label='user.change_password.action'}}
{{resetPasswordProgress}}
{{#unless showEnforcedNotice}}
{{cancel-link route="preferences.account" args= model.username}}
{{/unless}}
</div>
</div>
{{/if}}
{{/if}} {{/if}}
</form> </form>
{{/conditional-loading-spinner}}
</section> </section>

View File

@ -90,32 +90,11 @@
</label> </label>
{{/unless}} {{/unless}}
<div class="controls pref-second-factor"> <div class="controls pref-second-factor">
{{i18n 'user.second_factor.enable'}}
{{#if isCurrentUser}} {{#if isCurrentUser}}
{{#if model.second_factor_enabled}} {{#link-to "preferences.second-factor" class="btn btn-default"}}
{{#link-to "preferences.second-factor" class="btn btn-default"}} {{d-icon "lock"}} <span>{{i18n 'user.second_factor.enable'}}</span>
{{d-icon "unlock"}} <span>{{i18n 'user.second_factor.disable'}}</span> {{/link-to}}
{{/link-to}}
{{else}}
{{#link-to "preferences.second-factor" class="btn btn-default"}}
{{d-icon "lock"}} <span>{{i18n 'user.second_factor.enable'}}</span>
{{/link-to}}
{{/if}}
{{/if}}
</div>
<div class="controls pref-second-factor-backup">
{{#if model.second_factor_enabled}}
{{#if isCurrentUser}}
{{#link-to "preferences.second-factor-backup"}}
<span>
{{#if model.second_factor_backup_enabled}}
{{i18n 'user.second_factor_backup.manage'}}
{{else}}
{{i18n 'user.second_factor_backup.enable_long'}}
{{/if}}
</span>
{{/link-to}}
{{/if}}
{{/if}} {{/if}}
</div> </div>
</div> </div>

View File

@ -695,6 +695,21 @@
} }
} }
.second-factor {
&.instructions {
color: $primary-medium;
margin-top: 5px;
margin-bottom: 10px;
font-size: $font-down-1;
}
.second-factor-item {
margin-top: 0.75em;
}
.btn.edit {
min-height: auto;
}
}
.primary-textual .staged, .primary-textual .staged,
#user-card .staged { #user-card .staged {
font-style: italic; font-style: italic;

View File

@ -14,7 +14,8 @@ class UsersController < ApplicationController
requires_login only: [ requires_login only: [
:username, :update, :user_preferences_redirect, :upload_user_image, :username, :update, :user_preferences_redirect, :upload_user_image,
:pick_avatar, :destroy_user_image, :destroy, :check_emails, :pick_avatar, :destroy_user_image, :destroy, :check_emails,
:topic_tracking_state, :preferences, :create_second_factor, :topic_tracking_state, :preferences, :create_second_factor_totp,
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
:update_second_factor, :create_second_factor_backup, :select_avatar, :update_second_factor, :create_second_factor_backup, :select_avatar,
:notification_level, :revoke_auth_token :notification_level, :revoke_auth_token
] ]
@ -25,6 +26,10 @@ class UsersController < ApplicationController
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary :my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary
] ]
before_action :second_factor_check_confirmed_password, only: [
:create_second_factor_totp, :enable_second_factor_totp,
:disable_second_factor, :update_second_factor, :create_second_factor_backup]
before_action :respond_to_suspicious_request, only: [:create] before_action :respond_to_suspicious_request, only: [:create]
# we need to allow account creation with bad CSRF tokens, if people are caching, the CSRF token on the # we need to allow account creation with bad CSRF tokens, if people are caching, the CSRF token on the
@ -1063,39 +1068,32 @@ class UsersController < ApplicationController
render layout: 'no_ember' render layout: 'no_ember'
end end
def create_second_factor def list_second_factors
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed!
RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed!
unless current_user.confirm_password?(params[:password]) unless params[:password].empty?
return render json: failed_json.merge( RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed!
error: I18n.t("login.incorrect_password") RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed!
) unless current_user.confirm_password?(params[:password])
return render json: failed_json.merge(
error: I18n.t("login.incorrect_password")
)
end
secure_session["confirmed-password-#{current_user.id}"] = "true"
end end
qrcode_svg = RQRCode::QRCode.new(current_user.totp_provisioning_uri).as_svg( if secure_session["confirmed-password-#{current_user.id}"] == "true"
offset: 0, render json: success_json.merge(
color: '000', totps: current_user.totps.select(:id, :name, :last_used, :created_at, :method).order(:created_at)
shape_rendering: 'crispEdges', )
module_size: 4 else
) render json: success_json.merge(
password_required: true
render json: success_json.merge( )
key: current_user.user_second_factors.totp.data.scan(/.{4}/).join(" "), end
qr: qrcode_svg
)
end end
def create_second_factor_backup def create_second_factor_backup
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
unless current_user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code")
)
end
backup_codes = current_user.generate_backup_codes backup_codes = current_user.generate_backup_codes
render json: success_json.merge( render json: success_json.merge(
@ -1103,54 +1101,93 @@ class UsersController < ApplicationController
) )
end end
def update_second_factor def create_second_factor_totp
params.require(:second_factor_token) totp_data = ROTP::Base32.random_base32
params.require(:second_factor_method) secure_session["staged-totp-#{current_user.id}"] = totp_data
params.require(:second_factor_target) qrcode_svg = RQRCode::QRCode.new(current_user.totp_provisioning_uri(totp_data)).as_svg(
offset: 0,
color: '000',
shape_rendering: 'crispEdges',
module_size: 4
)
auth_method = params[:second_factor_method].to_i render json: success_json.merge(
key: totp_data.scan(/.{4}/).join(" "),
qr: qrcode_svg
)
end
def enable_second_factor_totp
params.require(:second_factor_token)
params.require(:name)
auth_token = params[:second_factor_token] auth_token = params[:second_factor_token]
update_second_factor_method = params[:second_factor_target].to_i totp_data = secure_session["staged-totp-#{current_user.id}"]
totp_object = current_user.get_totp_object(totp_data)
[request.remote_ip, current_user.id].each do |key| [request.remote_ip, current_user.id].each do |key|
RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed! RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed!
end end
authenticated = !auth_token.blank? && totp_object.verify_with_drift(auth_token, 30)
unless authenticated
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code")
)
end
current_user.create_totp(data: totp_data, name: params[:name], enabled: true)
render json: success_json
end
def disable_second_factor
# delete all second factors for a user
current_user.user_second_factors.destroy_all
Jobs.enqueue(
:critical_user_email,
type: :account_second_factor_disabled,
user_id: current_user.id
)
render json: success_json
end
def update_second_factor
params.require(:second_factor_target)
update_second_factor_method = params[:second_factor_target].to_i
if update_second_factor_method == UserSecondFactor.methods[:totp] if update_second_factor_method == UserSecondFactor.methods[:totp]
user_second_factor = current_user.user_second_factors.totp params.require(:id)
second_factor_id = params[:id].to_i
user_second_factor = current_user.user_second_factors.totps.find_by(id: second_factor_id)
elsif update_second_factor_method == UserSecondFactor.methods[:backup_codes] elsif update_second_factor_method == UserSecondFactor.methods[:backup_codes]
user_second_factor = current_user.user_second_factors.backup_codes user_second_factor = current_user.user_second_factors.backup_codes
end end
raise Discourse::InvalidParameters unless user_second_factor raise Discourse::InvalidParameters unless user_second_factor
unless current_user.authenticate_second_factor(auth_token, auth_method) if params[:name] && !params[:name].blank?
return render json: failed_json.merge( user_second_factor.update!(name: params[:name])
error: I18n.t("login.invalid_second_factor_code")
)
end end
if params[:disable] == "true"
if params[:enable] == "true" # Disabling backup codes deletes *all* backup codes
user_second_factor.update!(enabled: true) if update_second_factor_method == UserSecondFactor.methods[:backup_codes]
else
# when disabling totp, backup is disabled too
if update_second_factor_method == UserSecondFactor.methods[:totp]
current_user.user_second_factors.destroy_all
Jobs.enqueue(
:critical_user_email,
type: :account_second_factor_disabled,
user_id: current_user.id
)
elsif update_second_factor_method == UserSecondFactor.methods[:backup_codes]
current_user.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all current_user.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
else
user_second_factor.update!(enabled: false)
end end
end end
render json: success_json render json: success_json
end end
def second_factor_check_confirmed_password
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
raise Discourse::InvalidAccess.new unless current_user && secure_session["confirmed-password-#{current_user.id}"] == "true"
end
def revoke_account def revoke_account
user = fetch_user_from_params user = fetch_user_from_params
guardian.ensure_can_edit!(user) guardian.ensure_can_edit!(user)

View File

@ -3,35 +3,39 @@
module SecondFactorManager module SecondFactorManager
extend ActiveSupport::Concern extend ActiveSupport::Concern
def totp
self.create_totp
ROTP::TOTP.new(self.user_second_factors.totp.data, issuer: SiteSetting.title)
end
def create_totp(opts = {}) def create_totp(opts = {})
if !self.user_second_factors.totp UserSecondFactor.create!({
UserSecondFactor.create!({ user_id: self.id,
user_id: self.id, method: UserSecondFactor.methods[:totp],
method: UserSecondFactor.methods[:totp], data: ROTP::Base32.random_base32
data: ROTP::Base32.random_base32 }.merge(opts))
}.merge(opts))
end
end end
def totp_provisioning_uri def get_totp_object(data)
self.totp.provisioning_uri(self.email) ROTP::TOTP.new(data, issuer: SiteSetting.title)
end
def totp_provisioning_uri(data)
get_totp_object(data).provisioning_uri(self.email)
end end
def authenticate_totp(token) def authenticate_totp(token)
totp = self.totp totps = self&.user_second_factors.totps
last_used = 0 authenticated = false
totps.each do |totp|
if self.user_second_factors.totp.last_used last_used = 0
last_used = self.user_second_factors.totp.last_used.to_i
if totp.last_used
last_used = totp.last_used.to_i
end
authenticated = !token.blank? && totp.get_totp_object.verify_with_drift_and_prior(token, 30, last_used)
if authenticated
totp.update!(last_used: DateTime.now)
break
end
end end
authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 30, last_used)
self.user_second_factors.totp.update!(last_used: DateTime.now) if authenticated
!!authenticated !!authenticated
end end

View File

@ -11,6 +11,10 @@ class UserSecondFactor < ActiveRecord::Base
where(method: UserSecondFactor.methods[:totp], enabled: true) where(method: UserSecondFactor.methods[:totp], enabled: true)
end end
scope :all_totps, -> do
where(method: UserSecondFactor.methods[:totp])
end
def self.methods def self.methods
@methods ||= Enum.new( @methods ||= Enum.new(
totp: 1, totp: 1,
@ -18,8 +22,12 @@ class UserSecondFactor < ActiveRecord::Base
) )
end end
def self.totp def get_totp_object
where(method: self.methods[:totp]).first ROTP::TOTP.new(self.data, issuer: SiteSetting.title)
end
def totp_provisioning_uri
get_totp_object.provisioning_uri(user.email)
end end
end end

View File

@ -927,19 +927,19 @@ en:
disable: "Disable" disable: "Disable"
enable: "Enable" enable: "Enable"
enable_long: "Enable backup codes" enable_long: "Enable backup codes"
manage: "Manage backup codes" manage: "Manage backup codes. You have <strong>{{count}}</strong> backup codes remaining."
copied_to_clipboard: "Copied to Clipboard" copied_to_clipboard: "Copied to Clipboard"
copy_to_clipboard_error: "Error copying data to Clipboard" copy_to_clipboard_error: "Error copying data to Clipboard"
remaining_codes: "You have <strong>{{count}}</strong> backup codes remaining." remaining_codes: "You have <strong>{{count}}</strong> backup codes remaining."
use: "<a href>Use a backup code</a>" use: "<a href>Use a backup code</a>"
enable_prerequisites: "You must enable a primary second factor before generating backup codes."
codes: codes:
title: "Backup Codes Generated" title: "Backup Codes Generated"
description: "Each of these backup codes can only be used once. Keep them somewhere safe but accessible." description: "Each of these backup codes can only be used once. Keep them somewhere safe but accessible."
second_factor: second_factor:
title: "Two Factor Authentication" title: "Two Factor Authentication"
disable: "Disable Two Factor Authentication" enable: "Manage Two Factor Authentication"
enable: "Enable Two Factor Authentication"
confirm_password_description: "Please confirm your password to continue" confirm_password_description: "Please confirm your password to continue"
label: "Code" label: "Code"
rate_limit: "Please wait before trying another authentication code." rate_limit: "Please wait before trying another authentication code."
@ -954,6 +954,16 @@ en:
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."
use: "<a href>Use Authenticator app</a>" use: "<a href>Use Authenticator app</a>"
enforced_notice: "You are required to enable two factor authentication before accessing this site." enforced_notice: "You are required to enable two factor authentication before accessing this site."
disable: "disable"
disable_title: "Disable Second Factor"
disable_confirm: "Are you sure you want to disable all second factors?"
edit: "Edit"
edit_title: "Edit Second Factor"
edit_description: "Second Factor Name"
totp:
title: "Token-Based Authenticators"
add: "New Authenticator"
default_name: "My Authenticator"
change_about: change_about:
title: "Change About Me" title: "Change About Me"

View File

@ -373,8 +373,11 @@ Discourse::Application.routes.draw do
end end
end end
post "#{root_path}/second_factors" => "users#create_second_factor" post "#{root_path}/second_factors" => "users#list_second_factors"
put "#{root_path}/second_factor" => "users#update_second_factor" put "#{root_path}/second_factor" => "users#update_second_factor"
post "#{root_path}/create_second_factor_totp" => "users#create_second_factor_totp"
post "#{root_path}/enable_second_factor_totp" => "users#enable_second_factor_totp"
put "#{root_path}/disable_second_factor" => "users#disable_second_factor"
put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup" put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddNameToUserSecondFactors < ActiveRecord::Migration[5.2]
def change
add_column :user_second_factors, :name, :string
end
end

View File

@ -15,11 +15,11 @@ RSpec.describe SecondFactorManager do
totp = nil totp = nil
expect do expect do
totp = another_user.totp totp = another_user.create_totp(enabled: true)
end.to change { UserSecondFactor.count }.by(1) end.to change { UserSecondFactor.count }.by(1)
expect(totp.issuer).to eq(SiteSetting.title) expect(totp.get_totp_object.issuer).to eq(SiteSetting.title)
expect(totp.secret).to eq(another_user.reload.user_second_factors.totp.data) expect(totp.get_totp_object.secret).to eq(another_user.reload.user_second_factors.totps.first.data)
end end
end end
@ -31,17 +31,11 @@ RSpec.describe SecondFactorManager do
expect(second_factor.data).to be_present expect(second_factor.data).to be_present
expect(second_factor.enabled).to eq(true) expect(second_factor.enabled).to eq(true)
end end
describe 'when user has a second factor' do
it 'should return nil' do
expect(user.create_totp).to eq(nil)
end
end
end end
describe '#totp_provisioning_uri' do describe '#totp_provisioning_uri' do
it 'should return the right uri' do it 'should return the right uri' do
expect(user.totp_provisioning_uri).to eq( expect(user.user_second_factors.totps.first.totp_provisioning_uri).to eq(
"otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor_totp.data}&issuer=#{SiteSetting.title}" "otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor_totp.data}&issuer=#{SiteSetting.title}"
) )
end end
@ -50,12 +44,12 @@ RSpec.describe SecondFactorManager do
describe '#authenticate_totp' do describe '#authenticate_totp' do
it 'should be able to authenticate a token' do it 'should be able to authenticate a token' do
freeze_time do freeze_time do
expect(user.user_second_factors.totp.last_used).to eq(nil) expect(user.user_second_factors.totps.first.last_used).to eq(nil)
token = user.totp.now token = user.user_second_factors.totps.first.get_totp_object.now
expect(user.authenticate_totp(token)).to eq(true) expect(user.authenticate_totp(token)).to eq(true)
expect(user.user_second_factors.totp.last_used).to eq_time(DateTime.now) expect(user.user_second_factors.totps.first.last_used).to eq_time(DateTime.now)
expect(user.authenticate_totp(token)).to eq(false) expect(user.authenticate_totp(token)).to eq(false)
end end
end end
@ -63,14 +57,14 @@ RSpec.describe SecondFactorManager do
describe 'when token is blank' do describe 'when token is blank' do
it 'should be false' do it 'should be false' do
expect(user.authenticate_totp(nil)).to eq(false) expect(user.authenticate_totp(nil)).to eq(false)
expect(user.user_second_factors.totp.last_used).to eq(nil) expect(user.user_second_factors.totps.first.last_used).to eq(nil)
end end
end end
describe 'when token is invalid' do describe 'when token is invalid' do
it 'should be false' do it 'should be false' do
expect(user.authenticate_totp('111111')).to eq(false) expect(user.authenticate_totp('111111')).to eq(false)
expect(user.user_second_factors.totp.last_used).to eq(nil) expect(user.user_second_factors.totps.first.last_used).to eq(nil)
end end
end end
end end
@ -84,7 +78,7 @@ RSpec.describe SecondFactorManager do
describe "when user's second factor record is disabled" do describe "when user's second factor record is disabled" do
it 'should return false' do it 'should return false' do
user.user_second_factors.totp.update!(enabled: false) user.user_second_factors.totps.first.update!(enabled: false)
expect(user.totp_enabled?).to eq(false) expect(user.totp_enabled?).to eq(false)
end end
end end

View File

@ -937,7 +937,7 @@ RSpec.describe Admin::UsersController do
end end
describe '#disable_second_factor' do describe '#disable_second_factor' do
let(:second_factor) { user.create_totp } let(:second_factor) { user.create_totp(enabled: true) }
let(:second_factor_backup) { user.generate_backup_codes } let(:second_factor_backup) { user.generate_backup_codes }
describe 'as an admin' do describe 'as an admin' do
@ -945,7 +945,7 @@ RSpec.describe Admin::UsersController do
sign_in(admin) sign_in(admin)
second_factor second_factor
second_factor_backup second_factor_backup
expect(user.reload.user_second_factors.totp).to eq(second_factor) expect(user.reload.user_second_factors.totps.first).to eq(second_factor)
end end
it 'should able to disable the second factor for another user' do it 'should able to disable the second factor for another user' do

View File

@ -3163,7 +3163,7 @@ describe UsersController do
end end
end end
describe '#create_second_factor' do describe '#create_second_factor_totp' do
context 'when not logged in' do context 'when not logged in' do
it 'should return the right response' do it 'should return the right response' do
post "/users/second_factors.json", params: { post "/users/second_factors.json", params: {
@ -3181,24 +3181,17 @@ describe UsersController do
describe 'create 2fa request' do describe 'create 2fa request' do
it 'fails on incorrect password' do it 'fails on incorrect password' do
post "/users/second_factors.json", params: { ApplicationController.any_instance.expects(:secure_session).returns("confirmed-password-#{user.id}" => "false")
password: 'wrongpassword' post "/users/create_second_factor_totp.json"
}
expect(response.status).to eq(200) expect(response.status).to eq(403)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
"login.incorrect_password")
)
end end
describe 'when local logins are disabled' do describe 'when local logins are disabled' do
it 'should return the right response' do it 'should return the right response' do
SiteSetting.enable_local_logins = false SiteSetting.enable_local_logins = false
post "/users/second_factors.json", params: { post "/users/create_second_factor_totp.json"
password: 'myawesomepassword'
}
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
@ -3209,30 +3202,22 @@ describe UsersController do
SiteSetting.sso_url = 'http://someurl.com' SiteSetting.sso_url = 'http://someurl.com'
SiteSetting.enable_sso = true SiteSetting.enable_sso = true
post "/users/second_factors.json", params: { post "/users/create_second_factor_totp.json"
password: 'myawesomepassword'
}
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
it 'succeeds on correct password' do it 'succeeds on correct password' do
user.create_totp session = {}
user.user_second_factors.totp.update!(data: "abcdefghijklmnop") ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true")
post "/users/create_second_factor_totp.json"
post "/users/second_factors.json", params: {
password: 'myawesomepassword'
}
expect(response.status).to eq(200) expect(response.status).to eq(200)
response_body = JSON.parse(response.body) response_body = JSON.parse(response.body)
expect(response_body['key']).to eq( expect(response_body['key']).to be_present
"abcd efgh ijkl mnop"
)
expect(response_body['qr']).to be_present expect(response_body['qr']).to be_present
end end
end end
@ -3244,10 +3229,7 @@ describe UsersController do
context 'when not logged in' do context 'when not logged in' do
it 'should return the right response' do it 'should return the right response' do
put "/users/second_factor.json", params: { put "/users/second_factor.json"
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
@ -3263,52 +3245,39 @@ describe UsersController do
context 'when token is missing' do context 'when token is missing' do
it 'returns the right response' do it 'returns the right response' do
put "/users/second_factor.json", params: { put "/users/second_factor.json", params: {
enable: 'true', disable: 'true',
}
expect(response.status).to eq(400)
end
end
context 'when token is invalid' do
it 'returns the right response' do
put "/users/second_factor.json", params: {
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp],
second_factor_target: UserSecondFactor.methods[:totp], second_factor_target: UserSecondFactor.methods[:totp],
enable: 'true', id: user_second_factor.id
} }
expect(response.status).to eq(200) expect(response.status).to eq(403)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
"login.invalid_second_factor_code"
))
end end
end end
context 'when token is valid' do context 'when token is valid' do
it 'should allow second factor for the user to be enabled' do before do
ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true")
end
it 'should allow second factor for the user to be renamed' do
put "/users/second_factor.json", params: { put "/users/second_factor.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, name: 'renamed',
second_factor_method: UserSecondFactor.methods[:totp], second_factor_target: UserSecondFactor.methods[:totp],
second_factor_target: UserSecondFactor.methods[:totp], id: user_second_factor.id
enable: 'true' }
}
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(user.reload.user_second_factors.totp.enabled).to be true expect(user.reload.user_second_factors.totps.first.name).to eq("renamed")
end end
it 'should allow second factor for the user to be disabled' do it 'should allow second factor for the user to be disabled' do
put "/users/second_factor.json", params: { put "/users/second_factor.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, disable: 'true',
second_factor_method: UserSecondFactor.methods[:totp], second_factor_target: UserSecondFactor.methods[:totp],
second_factor_target: UserSecondFactor.methods[:totp] id: user_second_factor.id
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(user.reload.user_second_factors.totp).to eq(nil) expect(user.reload.user_second_factors.totps.first).to eq(nil)
end end
end end
end end
@ -3320,32 +3289,18 @@ describe UsersController do
second_factor_target: UserSecondFactor.methods[:backup_codes] second_factor_target: UserSecondFactor.methods[:backup_codes]
} }
expect(response.status).to eq(400) expect(response.status).to eq(403)
end
end
context 'when token is invalid' do
it 'returns the right response' do
put "/users/second_factor.json", params: {
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp],
second_factor_target: UserSecondFactor.methods[:backup_codes]
}
expect(response.status).to eq(200)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
"login.invalid_second_factor_code"
))
end end
end end
context 'when token is valid' do context 'when token is valid' do
before do
ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true")
end
it 'should allow second factor backup for the user to be disabled' do it 'should allow second factor backup for the user to be disabled' do
put "/users/second_factor.json", params: { put "/users/second_factor.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, second_factor_target: UserSecondFactor.methods[:backup_codes],
second_factor_method: UserSecondFactor.methods[:totp], disable: 'true'
second_factor_target: UserSecondFactor.methods[:backup_codes]
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -3377,26 +3332,17 @@ describe UsersController do
describe 'create 2fa request' do describe 'create 2fa request' do
it 'fails on incorrect password' do it 'fails on incorrect password' do
put "/users/second_factors_backup.json", params: { ApplicationController.any_instance.expects(:secure_session).returns("confirmed-password-#{user.id}" => "false")
second_factor_token: 'wrongtoken', put "/users/second_factors_backup.json"
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200) expect(response.status).to eq(403)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
"login.invalid_second_factor_code")
)
end end
describe 'when local logins are disabled' do describe 'when local logins are disabled' do
it 'should return the right response' do it 'should return the right response' do
SiteSetting.enable_local_logins = false SiteSetting.enable_local_logins = false
put "/users/second_factors_backup.json", params: { put "/users/second_factors_backup.json"
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
@ -3407,22 +3353,16 @@ describe UsersController do
SiteSetting.sso_url = 'http://someurl.com' SiteSetting.sso_url = 'http://someurl.com'
SiteSetting.enable_sso = true SiteSetting.enable_sso = true
put "/users/second_factors_backup.json", params: { put "/users/second_factors_backup.json"
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
it 'succeeds on correct password' do it 'succeeds on correct password' do
user_second_factor ApplicationController.any_instance.expects(:secure_session).returns("confirmed-password-#{user.id}" => "true")
put "/users/second_factors_backup.json", params: { put "/users/second_factors_backup.json"
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200) expect(response.status).to eq(200)

View File

@ -1,7 +1,15 @@
import { acceptance, updateCurrentUser } from "helpers/qunit-helpers"; import { acceptance, updateCurrentUser } from "helpers/qunit-helpers";
acceptance("Enforce Second Factor", { acceptance("Enforce Second Factor", {
loggedIn: true loggedIn: true,
pretend(server, helper) {
server.post("/u/second_factors.json", () => {
return helper.response({
success: "OK",
password_required: "true"
});
});
}
}); });
QUnit.test("as an admin", async assert => { QUnit.test("as an admin", async assert => {

View File

@ -1,18 +1,26 @@
import { acceptance, replaceCurrentUser } from "helpers/qunit-helpers";
import selectKit from "helpers/select-kit-helper"; import selectKit from "helpers/select-kit-helper";
import { acceptance } from "helpers/qunit-helpers";
import User from "discourse/models/user"; import User from "discourse/models/user";
acceptance("User Preferences", { acceptance("User Preferences", {
loggedIn: true, loggedIn: true,
pretend(server, helper) { pretend(server, helper) {
server.post("/u/second_factors.json", () => { server.post("/u/second_factors.json", () => {
return helper.response({
success: "OK",
password_required: "true"
});
});
server.post("/u/create_second_factor_totp.json", () => {
return helper.response({ return helper.response({
key: "rcyryaqage3jexfj", key: "rcyryaqage3jexfj",
qr: '<div id="test-qr">qr-code</div>' qr: '<div id="test-qr">qr-code</div>'
}); });
}); });
server.put("/u/second_factor.json", () => { server.post("/u/enable_second_factor_totp.json", () => {
return helper.response({ error: "invalid token" }); return helper.response({ error: "invalid token" });
}); });
@ -215,12 +223,13 @@ QUnit.test("second factor", async assert => {
await fillIn("#password", "secrets"); await fillIn("#password", "secrets");
await click(".user-preferences .btn-primary"); await click(".user-preferences .btn-primary");
assert.ok(exists("#test-qr"), "shows qr code");
assert.notOk(exists("#password"), "it hides the password input"); assert.notOk(exists("#password"), "it hides the password input");
await click(".new-totp");
assert.ok(exists("#test-qr"), "shows qr code");
await fillIn("#second-factor-token", "111111"); await fillIn("#second-factor-token", "111111");
await click(".btn-primary"); await click(".add-totp");
assert.ok( assert.ok(
find(".alert-error") find(".alert-error")
@ -230,20 +239,6 @@ QUnit.test("second factor", async assert => {
); );
}); });
QUnit.test("second factor backup", async assert => {
await visit("/u/eviltrout/preferences/second-factor-backup");
assert.ok(
exists("#second-factor-token"),
"it has a authentication token input"
);
await fillIn("#second-factor-token", "111111");
await click(".user-preferences .btn-primary");
assert.ok(exists(".backup-codes-area"), "shows backup codes");
});
QUnit.test("default avatar selector", async assert => { QUnit.test("default avatar selector", async assert => {
await visit("/u/eviltrout/preferences"); await visit("/u/eviltrout/preferences");
@ -259,6 +254,40 @@ QUnit.test("default avatar selector", async assert => {
); );
}); });
acceptance("Second Factor Backups", {
loggedIn: true,
pretend(server, helper) {
server.post("/u/second_factors.json", () => {
return helper.response({
success: "OK",
totps: [{ id: 1, name: "one of them" }]
});
});
server.put("/u/second_factors_backup.json", () => {
return helper.response({
backup_codes: ["dsffdsd", "fdfdfdsf", "fddsds"]
});
});
server.get("/u/eviltrout/activity.json", () => {
return helper.response({});
});
}
});
QUnit.test("second factor backup", async assert => {
replaceCurrentUser({ second_factor_enabled: true });
await visit("/u/eviltrout/preferences/second-factor");
await click(".edit-2fa-backup");
assert.ok(
exists(".second-factor-backup-preferences"),
"shows the 2fa backup panel"
);
await click(".second-factor-backup-preferences .btn-primary");
assert.ok(exists(".backup-codes-area"), "shows backup codes");
});
acceptance("Avatar selector when selectable avatars is enabled", { acceptance("Avatar selector when selectable avatars is enabled", {
loggedIn: true, loggedIn: true,
settings: { selectable_avatars_enabled: true }, settings: { selectable_avatars_enabled: true },