mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Second factor backup
This commit is contained in:
parent
c73f98c289
commit
ec3e6a81a4
@ -0,0 +1,55 @@
|
|||||||
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
|
||||||
|
function b64EncodeUnicode(str) {
|
||||||
|
return btoa(
|
||||||
|
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(
|
||||||
|
match,
|
||||||
|
p1
|
||||||
|
) {
|
||||||
|
return String.fromCharCode("0x" + p1);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
classNames: ["backup-codes"],
|
||||||
|
backupCodes: null,
|
||||||
|
|
||||||
|
click(event) {
|
||||||
|
if (event.target.id === "backupCodes") {
|
||||||
|
this._selectAllBackupCodes();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
didRender() {
|
||||||
|
this._super();
|
||||||
|
|
||||||
|
const $backupCodes = this.$("#backupCodes");
|
||||||
|
if ($backupCodes.length) {
|
||||||
|
$backupCodes.height($backupCodes[0].scrollHeight);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("formattedBackupCodes") base64BackupCode: b64EncodeUnicode,
|
||||||
|
|
||||||
|
@computed("backupCodes")
|
||||||
|
formattedBackupCodes(backupCodes) {
|
||||||
|
if (!backupCodes) return null;
|
||||||
|
|
||||||
|
return backupCodes.join("\n").trim();
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
copyToClipboard() {
|
||||||
|
this._selectAllBackupCodes();
|
||||||
|
this.get("copyBackupCode")(document.execCommand("copy"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_selectAllBackupCodes() {
|
||||||
|
const $textArea = this.$("#backupCodes");
|
||||||
|
$textArea[0].focus();
|
||||||
|
$textArea[0].setSelectionRange(0, this.get("formattedBackupCodes").length);
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,37 @@
|
|||||||
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
secondFactorTitle(secondFactorMethod) {
|
||||||
|
return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
|
||||||
|
? I18n.t("login.second_factor_title")
|
||||||
|
: I18n.t("login.second_factor_backup_title");
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
secondFactorDescription(secondFactorMethod) {
|
||||||
|
return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
|
||||||
|
? I18n.t("login.second_factor_description")
|
||||||
|
: I18n.t("login.second_factor_backup_description");
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
linkText(secondFactorMethod) {
|
||||||
|
return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP
|
||||||
|
? "login.second_factor_backup"
|
||||||
|
: "login.second_factor";
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
toggleSecondFactorMethod() {
|
||||||
|
const secondFactorMethod = this.get("secondFactorMethod");
|
||||||
|
this.set("loginSecondFactor", "");
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) {
|
||||||
|
this.set("secondFactorMethod", SECOND_FACTOR_METHODS.BACKUP_CODE);
|
||||||
|
} else {
|
||||||
|
this.set("secondFactorMethod", SECOND_FACTOR_METHODS.TOTP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,23 @@
|
|||||||
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
type(secondFactorMethod) {
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "tel";
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "text";
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
pattern(secondFactorMethod) {
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "[0-9]{6}";
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE)
|
||||||
|
return "[a-z0-9]{16}";
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("secondFactorMethod")
|
||||||
|
maxlength(secondFactorMethod) {
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "6";
|
||||||
|
if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "16";
|
||||||
|
}
|
||||||
|
});
|
@ -7,6 +7,7 @@ import { escape } from "pretty-text/sanitizer";
|
|||||||
import { escapeExpression } from "discourse/lib/utilities";
|
import { escapeExpression } from "discourse/lib/utilities";
|
||||||
import { extractError } from "discourse/lib/ajax-error";
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
import computed from "ember-addons/ember-computed-decorators";
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||||
|
|
||||||
// This is happening outside of the app via popup
|
// This is happening outside of the app via popup
|
||||||
const AuthErrors = [
|
const AuthErrors = [
|
||||||
@ -31,6 +32,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
|||||||
canLoginLocal: setting("enable_local_logins"),
|
canLoginLocal: setting("enable_local_logins"),
|
||||||
canLoginLocalWithEmail: setting("enable_local_logins_via_email"),
|
canLoginLocalWithEmail: setting("enable_local_logins_via_email"),
|
||||||
loginRequired: Em.computed.alias("application.loginRequired"),
|
loginRequired: Em.computed.alias("application.loginRequired"),
|
||||||
|
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
|
||||||
|
|
||||||
resetForm: function() {
|
resetForm: function() {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
@ -103,14 +105,14 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
|||||||
data: {
|
data: {
|
||||||
login: this.get("loginName"),
|
login: this.get("loginName"),
|
||||||
password: this.get("loginPassword"),
|
password: this.get("loginPassword"),
|
||||||
second_factor_token: this.get("loginSecondFactor")
|
second_factor_token: this.get("loginSecondFactor"),
|
||||||
|
second_factor_method: this.get("secondFactorMethod")
|
||||||
}
|
}
|
||||||
}).then(
|
}).then(
|
||||||
function(result) {
|
function(result) {
|
||||||
// Successful login
|
// Successful login
|
||||||
if (result && result.error) {
|
if (result && result.error) {
|
||||||
self.set("loggingIn", false);
|
self.set("loggingIn", false);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result.reason === "invalid_second_factor" &&
|
result.reason === "invalid_second_factor" &&
|
||||||
!self.get("secondFactorRequired")
|
!self.get("secondFactorRequired")
|
||||||
@ -118,7 +120,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
|||||||
$("#modal-alert").hide();
|
$("#modal-alert").hide();
|
||||||
self.setProperties({
|
self.setProperties({
|
||||||
secondFactorRequired: true,
|
secondFactorRequired: true,
|
||||||
showLoginButtons: false
|
showLoginButtons: false,
|
||||||
|
backupEnabled: result.backup_enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#credentials").hide();
|
$("#credentials").hide();
|
||||||
|
@ -4,11 +4,14 @@ import DiscourseURL from "discourse/lib/url";
|
|||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import PasswordValidation from "discourse/mixins/password-validation";
|
import PasswordValidation from "discourse/mixins/password-validation";
|
||||||
import { userPath } from "discourse/lib/url";
|
import { userPath } from "discourse/lib/url";
|
||||||
|
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||||
|
|
||||||
export default Ember.Controller.extend(PasswordValidation, {
|
export default Ember.Controller.extend(PasswordValidation, {
|
||||||
isDeveloper: Ember.computed.alias("model.is_developer"),
|
isDeveloper: Ember.computed.alias("model.is_developer"),
|
||||||
admin: Ember.computed.alias("model.admin"),
|
admin: Ember.computed.alias("model.admin"),
|
||||||
secondFactorRequired: Ember.computed.alias("model.second_factor_required"),
|
secondFactorRequired: Ember.computed.alias("model.second_factor_required"),
|
||||||
|
backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
|
||||||
|
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
|
||||||
passwordRequired: true,
|
passwordRequired: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
@ -36,7 +39,8 @@ export default Ember.Controller.extend(PasswordValidation, {
|
|||||||
type: "PUT",
|
type: "PUT",
|
||||||
data: {
|
data: {
|
||||||
password: this.get("accountPassword"),
|
password: this.get("accountPassword"),
|
||||||
second_factor_token: this.get("secondFactor")
|
second_factor_token: this.get("secondFactor"),
|
||||||
|
second_factor_method: this.get("secondFactorMethod")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(result => {
|
.then(result => {
|
||||||
@ -50,7 +54,7 @@ export default Ember.Controller.extend(PasswordValidation, {
|
|||||||
DiscourseURL.redirectTo(result.redirect_to || "/");
|
DiscourseURL.redirectTo(result.redirect_to || "/");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (result.errors && result.errors.user_second_factor) {
|
if (result.errors && result.errors.user_second_factors) {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
secondFactorRequired: true,
|
secondFactorRequired: true,
|
||||||
password: null,
|
password: null,
|
||||||
|
@ -0,0 +1,109 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export default Ember.Controller.extend({
|
||||||
|
loading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
successMessage: null,
|
||||||
|
backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
|
||||||
|
backupCodes: null,
|
||||||
|
|
||||||
|
@computed("secondFactorToken")
|
||||||
|
isValidSecondFactorToken(secondFactorToken) {
|
||||||
|
return secondFactorToken && secondFactorToken.length === 6;
|
||||||
|
},
|
||||||
|
|
||||||
|
@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")
|
||||||
|
generateBackupCodeBtnLabel(backupEnabled) {
|
||||||
|
return backupEnabled
|
||||||
|
? "user.second_factor_backup.regenerate"
|
||||||
|
: "user.second_factor_backup.enable";
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
copyBackupCode(successful) {
|
||||||
|
if (successful) {
|
||||||
|
this.set(
|
||||||
|
"successMessage",
|
||||||
|
I18n.t("user.second_factor_backup.copied_to_clipboard")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.set(
|
||||||
|
"errorMessage",
|
||||||
|
I18n.t("user.second_factor_backup.copy_to_clipboard_error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._hideCopyMessage();
|
||||||
|
},
|
||||||
|
|
||||||
|
disableSecondFactorBackup() {
|
||||||
|
this.set("backupCodes", []);
|
||||||
|
|
||||||
|
if (!this.get("secondFactorToken")) return;
|
||||||
|
|
||||||
|
this.set("loading", true);
|
||||||
|
|
||||||
|
this.get("content")
|
||||||
|
.toggleSecondFactor(this.get("secondFactorToken"), false, 2)
|
||||||
|
.then(response => {
|
||||||
|
if (response.error) {
|
||||||
|
this.set("errorMessage", response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("errorMessage", null);
|
||||||
|
|
||||||
|
const usernameLower = this.get("content").username.toLowerCase();
|
||||||
|
DiscourseURL.redirectTo(userPath(`${usernameLower}/preferences`));
|
||||||
|
})
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => this.set("loading", false));
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSecondFactorCodes() {
|
||||||
|
if (!this.get("secondFactorToken")) return;
|
||||||
|
const model = this.get("model");
|
||||||
|
this.set("loading", true);
|
||||||
|
this.get("content")
|
||||||
|
.generateSecondFactorCodes(this.get("secondFactorToken"))
|
||||||
|
.then(response => {
|
||||||
|
if (response.error) {
|
||||||
|
this.set("errorMessage", response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setProperties({
|
||||||
|
errorMessage: null,
|
||||||
|
backupCodes: response.backup_codes
|
||||||
|
});
|
||||||
|
model.set("second_factor_backup_enabled", true);
|
||||||
|
})
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => {
|
||||||
|
this.setProperties({
|
||||||
|
loading: false,
|
||||||
|
secondFactorToken: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_hideCopyMessage() {
|
||||||
|
Ember.run.later(
|
||||||
|
() => this.setProperties({ successMessage: null, errorMessage: null }),
|
||||||
|
2000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
@ -43,7 +43,7 @@ export default Ember.Controller.extend({
|
|||||||
this.set("loading", true);
|
this.set("loading", true);
|
||||||
|
|
||||||
this.get("content")
|
this.get("content")
|
||||||
.toggleSecondFactor(this.get("secondFactorToken"), enable)
|
.toggleSecondFactor(this.get("secondFactorToken"), enable, 1)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
this.set("errorMessage", response.error);
|
this.set("errorMessage", response.error);
|
||||||
|
@ -19,6 +19,8 @@ import PreloadStore from "preload-store";
|
|||||||
import { defaultHomepage } from "discourse/lib/utilities";
|
import { defaultHomepage } from "discourse/lib/utilities";
|
||||||
import { userPath } from "discourse/lib/url";
|
import { userPath } from "discourse/lib/url";
|
||||||
|
|
||||||
|
export const SECOND_FACTOR_METHODS = { TOTP: 1, BACKUP_CODE: 2 };
|
||||||
|
|
||||||
const isForever = dt => moment().diff(dt, "years") < -500;
|
const isForever = dt => moment().diff(dt, "years") < -500;
|
||||||
|
|
||||||
const User = RestModel.extend({
|
const User = RestModel.extend({
|
||||||
@ -352,9 +354,20 @@ const User = RestModel.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSecondFactor(token, enable) {
|
toggleSecondFactor(token, enable, method) {
|
||||||
return ajax("/u/second_factor.json", {
|
return ajax("/u/second_factor.json", {
|
||||||
data: { second_factor_token: token, enable },
|
data: {
|
||||||
|
second_factor_token: token,
|
||||||
|
second_factor_method: method,
|
||||||
|
enable
|
||||||
|
},
|
||||||
|
type: "PUT"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSecondFactorCodes(token) {
|
||||||
|
return ajax("/u/second_factors_backup.json", {
|
||||||
|
data: { second_factor_token: token },
|
||||||
type: "PUT"
|
type: "PUT"
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -156,6 +156,7 @@ export default function() {
|
|||||||
this.route("username");
|
this.route("username");
|
||||||
this.route("email");
|
this.route("email");
|
||||||
this.route("second-factor");
|
this.route("second-factor");
|
||||||
|
this.route("second-factor-backup");
|
||||||
this.route("about", { path: "/about-me" });
|
this.route("about", { path: "/about-me" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
import RestrictedUserRoute from "discourse/routes/restricted-user";
|
||||||
|
|
||||||
|
export default RestrictedUserRoute.extend({
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,15 @@
|
|||||||
|
<div class="wrapper">
|
||||||
|
<textarea id="backupCodes" class="backup-codes-area" readonly>{{formattedBackupCodes}}</textarea>
|
||||||
|
|
||||||
|
{{d-button
|
||||||
|
action="copyToClipboard"
|
||||||
|
class="backup-codes-copy-btn"
|
||||||
|
icon="copy"}}
|
||||||
|
|
||||||
|
<a download="backup_codes.txt"
|
||||||
|
class="btn no-text btn-icon backup-codes-download-btn"
|
||||||
|
target="_blank"
|
||||||
|
href="data:application/octet-stream;charset=utf-8;base64,{{base64BackupCode}}">
|
||||||
|
{{d-icon "download"}}
|
||||||
|
</a>
|
||||||
|
</div>
|
@ -1,13 +1,13 @@
|
|||||||
<div id="second-factor" style="display: none;">
|
<div id="second-factor">
|
||||||
<h3>{{i18n 'login.second_factor_title'}}</h3>
|
<h3>{{secondFactorTitle}}</h3>
|
||||||
<p>{{i18n 'login.second_factor_description'}}</p>
|
<p>{{secondFactorDescription}}</p>
|
||||||
|
{{yield}}
|
||||||
<table>
|
{{#if backupEnabled}}
|
||||||
<tr>
|
<p>
|
||||||
<td>
|
{{discourse-linked-text
|
||||||
{{yield}}
|
class="toggle-second-factor-method"
|
||||||
</td>
|
action="toggleSecondFactorMethod"
|
||||||
<td></td>
|
text=linkText}}
|
||||||
</tr>
|
</p>
|
||||||
</table>
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{{text-field value=value
|
{{text-field value=value
|
||||||
type="tel"
|
type=type
|
||||||
pattern='[0-9]{6}'
|
pattern=pattern
|
||||||
maxlength='6'
|
maxlength=maxlength
|
||||||
|
class="second-factor-token-input"
|
||||||
id=inputId
|
id=inputId
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
|
@ -46,12 +46,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{#second-factor-form}}
|
{{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}}
|
||||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor'}}
|
{{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}}
|
||||||
{{/second-factor-form}}
|
{{/second-factor-form}}
|
||||||
</form>
|
</form>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{/d-modal-body}}
|
{{/d-modal-body}}
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@ -28,12 +28,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{#second-factor-form}}
|
{{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}}
|
||||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor'}}
|
{{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}}
|
||||||
{{/second-factor-form}}
|
{{/second-factor-form}}
|
||||||
</form>
|
</form>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showLoginButtons}}
|
{{#if showLoginButtons}}
|
||||||
{{login-buttons
|
{{login-buttons
|
||||||
canLoginLocalWithEmail=canLoginLocalWithEmail
|
canLoginLocalWithEmail=canLoginLocalWithEmail
|
||||||
|
@ -17,11 +17,9 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<form>
|
<form>
|
||||||
{{#if secondFactorRequired}}
|
{{#if secondFactorRequired}}
|
||||||
<h2>{{i18n 'login.second_factor_title'}}</h2>
|
{{#second-factor-form secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
|
||||||
<p>{{i18n 'login.second_factor_description'}}</p>
|
{{text-field value=secondFactor id="second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus" secondFactorMethod=secondFactorMethod}}
|
||||||
<div class="input">
|
{{/second-factor-form}}
|
||||||
{{input value=secondFactor id="second-factor" autofocus="autofocus"}}
|
|
||||||
</div>
|
|
||||||
{{d-button action="submit" class='btn-primary' label='submit'}}
|
{{d-button action="submit" class='btn-primary' label='submit'}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2>{{i18n 'user.change_password.choose'}}</h2>
|
<h2>{{i18n 'user.change_password.choose'}}</h2>
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
<section class="user-content user-preferences second-factor-backup-preferences">
|
||||||
|
<form class="form-horizontal">
|
||||||
|
<h2>{{i18n "user.second_factor_backup.title"}}</h2>
|
||||||
|
|
||||||
|
{{#if successMessage}}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
{{successMessage}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if errorMessage}}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{errorMessage}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="second-factor-form">
|
||||||
|
{{second-factor-input
|
||||||
|
value=secondFactorToken
|
||||||
|
maxlength=6
|
||||||
|
inputId="second-factor-token"}}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
{{d-button
|
||||||
|
action="generateSecondFactorCodes"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=isDisabledGenerateBackupCodeBtn
|
||||||
|
label=generateBackupCodeBtnLabel}}
|
||||||
|
{{#if backupEnabled}}
|
||||||
|
{{d-button
|
||||||
|
action="disableSecondFactorBackup"
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled=isDisabledDisableBackupCodeBtn
|
||||||
|
label="user.second_factor_backup.disable"}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
{{i18n "user.second_factor.disable_description"}}
|
||||||
|
</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}}
|
||||||
|
|
||||||
|
{{#link-to "preferences.account" model.username}}
|
||||||
|
{{i18n "go_back"}}
|
||||||
|
{{/link-to}}
|
||||||
|
</form>
|
||||||
|
</section>
|
@ -80,6 +80,22 @@
|
|||||||
{{/link-to}}
|
{{/link-to}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="controls pref-second-factor-backup">
|
||||||
|
{{#if model.second_factor_enabled}}
|
||||||
|
{{#if model.second_factor_backup_enabled}}
|
||||||
|
{{i18n 'user.second_factor_backup.manage'}}
|
||||||
|
{{else}}
|
||||||
|
{{i18n 'user.second_factor_backup.enable_long'}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isCurrentUser}}
|
||||||
|
{{#link-to "preferences.second-factor-backup" class="btn btn-small btn-icon pad-left no-text"}}
|
||||||
|
{{d-icon "pencil"}}
|
||||||
|
{{/link-to}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
@ -555,6 +555,68 @@
|
|||||||
.tag-notifications .tag-controls {
|
.tag-notifications .tag-controls {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.second-factor-backup-preferences {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
.second-factor-token-input {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.second-factor-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-horizontal .instructions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes {
|
||||||
|
margin: 2em 0;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid $primary-low;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-area {
|
||||||
|
resize: none;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
text-align: center;
|
||||||
|
width: 250px;
|
||||||
|
background: white;
|
||||||
|
border: 0;
|
||||||
|
cursor: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
outline: none;
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: #e9e9e9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-copy-btn,
|
||||||
|
.backup-codes-download-btn {
|
||||||
|
right: 5px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-copy-btn {
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-download-btn {
|
||||||
|
top: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginated-topics-list {
|
.paginated-topics-list {
|
||||||
|
@ -54,6 +54,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#second-factor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// styles used on the
|
// styles used on the
|
||||||
|
@ -130,6 +130,9 @@
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#second-factor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// styles for the
|
// styles for the
|
||||||
|
@ -386,10 +386,10 @@ class Admin::UsersController < Admin::AdminController
|
|||||||
|
|
||||||
def disable_second_factor
|
def disable_second_factor
|
||||||
guardian.ensure_can_disable_second_factor!(@user)
|
guardian.ensure_can_disable_second_factor!(@user)
|
||||||
user_second_factor = @user.user_second_factor
|
user_second_factor = @user.user_second_factors
|
||||||
raise Discourse::InvalidParameters unless user_second_factor
|
raise Discourse::InvalidParameters unless !user_second_factor.empty?
|
||||||
|
|
||||||
user_second_factor.destroy!
|
user_second_factor.destroy_all
|
||||||
StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user)
|
StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user)
|
||||||
|
|
||||||
Jobs.enqueue(
|
Jobs.enqueue(
|
||||||
|
@ -246,10 +246,11 @@ class SessionController < ApplicationController
|
|||||||
if payload = login_error_check(user)
|
if payload = login_error_check(user)
|
||||||
render json: payload
|
render json: payload
|
||||||
else
|
else
|
||||||
if user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token])
|
if user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
|
||||||
return render json: failed_json.merge(
|
return render json: failed_json.merge(
|
||||||
error: I18n.t("login.invalid_second_factor_code"),
|
error: I18n.t("login.invalid_second_factor_code"),
|
||||||
reason: "invalid_second_factor"
|
reason: "invalid_second_factor",
|
||||||
|
backup_enabled: user.backup_codes_enabled?
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -260,17 +261,18 @@ class SessionController < ApplicationController
|
|||||||
def email_login
|
def email_login
|
||||||
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
|
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
|
||||||
second_factor_token = params[:second_factor_token]
|
second_factor_token = params[:second_factor_token]
|
||||||
|
second_factor_method = params[:second_factor_method].to_i
|
||||||
token = params[:token]
|
token = params[:token]
|
||||||
valid_token = !!EmailToken.valid_token_format?(token)
|
valid_token = !!EmailToken.valid_token_format?(token)
|
||||||
user = EmailToken.confirmable(token)&.user
|
user = EmailToken.confirmable(token)&.user
|
||||||
|
|
||||||
if valid_token && user&.totp_enabled?
|
if valid_token && user&.totp_enabled?
|
||||||
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
|
||||||
|
|
||||||
if !second_factor_token.present?
|
if !second_factor_token.present?
|
||||||
@second_factor_required = true
|
@second_factor_required = true
|
||||||
|
@backup_codes_enabled = true if user&.backup_codes_enabled?
|
||||||
return render layout: 'no_ember'
|
return render layout: 'no_ember'
|
||||||
elsif !user.authenticate_totp(second_factor_token)
|
elsif !user.authenticate_second_factor(second_factor_token, second_factor_method)
|
||||||
|
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
||||||
@error = I18n.t('login.invalid_second_factor_code')
|
@error = I18n.t('login.invalid_second_factor_code')
|
||||||
return render layout: 'no_ember'
|
return render layout: 'no_ember'
|
||||||
end
|
end
|
||||||
|
@ -12,7 +12,7 @@ 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, :topic_tracking_state,
|
:pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state,
|
||||||
:preferences, :create_second_factor, :update_second_factor
|
:preferences, :create_second_factor, :update_second_factor, :create_second_factor_backup
|
||||||
]
|
]
|
||||||
|
|
||||||
skip_before_action :check_xhr, only: [
|
skip_before_action :check_xhr, only: [
|
||||||
@ -454,7 +454,7 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
totp_enabled = @user&.totp_enabled?
|
totp_enabled = @user&.totp_enabled?
|
||||||
|
|
||||||
if !totp_enabled || @user.authenticate_totp(params[:second_factor_token])
|
if !totp_enabled || @user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
|
||||||
secure_session["second-factor-#{token}"] = "true"
|
secure_session["second-factor-#{token}"] = "true"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -467,7 +467,7 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
if !valid_second_factor
|
if !valid_second_factor
|
||||||
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
||||||
@user.errors.add(:user_second_factor, :invalid)
|
@user.errors.add(:user_second_factors, :invalid)
|
||||||
@error = I18n.t('login.invalid_second_factor_code')
|
@error = I18n.t('login.invalid_second_factor_code')
|
||||||
elsif @invalid_password
|
elsif @invalid_password
|
||||||
@user.errors.add(:password, :invalid)
|
@user.errors.add(:password, :invalid)
|
||||||
@ -494,7 +494,8 @@ class UsersController < ApplicationController
|
|||||||
MultiJson.dump(
|
MultiJson.dump(
|
||||||
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
||||||
admin: @user.admin?,
|
admin: @user.admin?,
|
||||||
second_factor_required: !valid_second_factor
|
second_factor_required: !valid_second_factor,
|
||||||
|
backup_enabled: @user.backup_codes_enabled?
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@ -524,7 +525,8 @@ class UsersController < ApplicationController
|
|||||||
render json: {
|
render json: {
|
||||||
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
||||||
admin: @user.admin?,
|
admin: @user.admin?,
|
||||||
second_factor_required: !valid_second_factor
|
second_factor_required: !valid_second_factor,
|
||||||
|
backup_enabled: @user.backup_codes_enabled?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -575,16 +577,19 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
email_token_user = EmailToken.confirmable(token)&.user
|
email_token_user = EmailToken.confirmable(token)&.user
|
||||||
totp_enabled = email_token_user&.totp_enabled?
|
totp_enabled = email_token_user&.totp_enabled?
|
||||||
|
backup_enabled = email_token_user&.backup_codes_enabled?
|
||||||
second_factor_token = params[:second_factor_token]
|
second_factor_token = params[:second_factor_token]
|
||||||
|
second_factor_method = params[:second_factor_method].to_i
|
||||||
confirm_email = false
|
confirm_email = false
|
||||||
|
|
||||||
confirm_email =
|
confirm_email =
|
||||||
if totp_enabled
|
if totp_enabled
|
||||||
@second_factor_required = true
|
@second_factor_required = true
|
||||||
|
@backup_codes_enabled = true
|
||||||
@message = I18n.t("login.second_factor_title")
|
@message = I18n.t("login.second_factor_title")
|
||||||
|
|
||||||
if second_factor_token.present?
|
if second_factor_token.present?
|
||||||
if email_token_user.authenticate_totp(second_factor_token)
|
if email_token_user.authenticate_second_factor(second_factor_token, second_factor_method)
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
@error = I18n.t("login.invalid_second_factor_code")
|
@error = I18n.t("login.invalid_second_factor_code")
|
||||||
@ -956,19 +961,43 @@ class UsersController < ApplicationController
|
|||||||
)
|
)
|
||||||
|
|
||||||
render json: success_json.merge(
|
render json: success_json.merge(
|
||||||
key: current_user.user_second_factor.data.scan(/.{4}/).join(" "),
|
key: current_user.user_second_factors.totp.data.scan(/.{4}/).join(" "),
|
||||||
qr: qrcode_svg
|
qr: qrcode_svg
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_second_factor_backup
|
||||||
|
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
|
||||||
|
|
||||||
|
unless current_user.authenticate_totp(params[:second_factor_token])
|
||||||
|
return render json: failed_json.merge(
|
||||||
|
error: I18n.t("login.invalid_second_factor_code")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
backup_codes = current_user.generate_backup_codes
|
||||||
|
|
||||||
|
render json: success_json.merge(
|
||||||
|
backup_codes: backup_codes
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def update_second_factor
|
def update_second_factor
|
||||||
params.require(:second_factor_token)
|
params.require(:second_factor_token)
|
||||||
|
params.require(:second_factor_method)
|
||||||
|
|
||||||
|
second_factor_method = params[:second_factor_method].to_i
|
||||||
|
|
||||||
[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
|
||||||
|
|
||||||
user_second_factor = current_user.user_second_factor
|
if second_factor_method == UserSecondFactor.methods[:totp]
|
||||||
|
user_second_factor = current_user.user_second_factors.totp
|
||||||
|
elsif second_factor_method == UserSecondFactor.methods[:backup_codes]
|
||||||
|
user_second_factor = current_user.user_second_factors.backup_codes
|
||||||
|
end
|
||||||
|
|
||||||
raise Discourse::InvalidParameters unless user_second_factor
|
raise Discourse::InvalidParameters unless user_second_factor
|
||||||
|
|
||||||
unless current_user.authenticate_totp(params[:second_factor_token])
|
unless current_user.authenticate_totp(params[:second_factor_token])
|
||||||
@ -980,13 +1009,18 @@ class UsersController < ApplicationController
|
|||||||
if params[:enable] == "true"
|
if params[:enable] == "true"
|
||||||
user_second_factor.update!(enabled: true)
|
user_second_factor.update!(enabled: true)
|
||||||
else
|
else
|
||||||
user_second_factor.destroy!
|
# when disabling totp, backup is disabled too
|
||||||
|
if second_factor_method == UserSecondFactor.methods[:totp]
|
||||||
|
current_user.user_second_factors.destroy_all
|
||||||
|
|
||||||
Jobs.enqueue(
|
Jobs.enqueue(
|
||||||
:critical_user_email,
|
:critical_user_email,
|
||||||
type: :account_second_factor_disabled,
|
type: :account_second_factor_disabled,
|
||||||
user_id: current_user.id
|
user_id: current_user.id
|
||||||
)
|
)
|
||||||
|
elsif second_factor_method == UserSecondFactor.methods[:backup_codes]
|
||||||
|
current_user.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: success_json
|
render json: success_json
|
||||||
|
@ -43,9 +43,10 @@ class UsersEmailController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] &&
|
if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] &&
|
||||||
user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token])
|
user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
|
||||||
|
|
||||||
@update_result = :invalid_second_factor
|
@update_result = :invalid_second_factor
|
||||||
|
@backup_codes_enabled = true if user.backup_codes_enabled?
|
||||||
|
|
||||||
if params[:second_factor_token].present?
|
if params[:second_factor_token].present?
|
||||||
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
||||||
|
@ -3,12 +3,13 @@ module SecondFactorManager
|
|||||||
|
|
||||||
def totp
|
def totp
|
||||||
self.create_totp
|
self.create_totp
|
||||||
ROTP::TOTP.new(self.user_second_factor.data, issuer: SiteSetting.title)
|
ROTP::TOTP.new(self.user_second_factors.totp.data, issuer: SiteSetting.title)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_totp(opts = {})
|
def create_totp(opts = {})
|
||||||
if !self.user_second_factor
|
if !self.user_second_factors.totp
|
||||||
self.create_user_second_factor!({
|
UserSecondFactor.create!({
|
||||||
|
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))
|
||||||
@ -23,18 +24,88 @@ module SecondFactorManager
|
|||||||
totp = self.totp
|
totp = self.totp
|
||||||
last_used = 0
|
last_used = 0
|
||||||
|
|
||||||
if self.user_second_factor.last_used
|
if self.user_second_factors.totp.last_used
|
||||||
last_used = self.user_second_factor.last_used.to_i
|
last_used = self.user_second_factors.totp.last_used.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 30, last_used)
|
authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 30, last_used)
|
||||||
self.user_second_factor.update!(last_used: DateTime.now) if authenticated
|
self.user_second_factors.totp.update!(last_used: DateTime.now) if authenticated
|
||||||
!!authenticated
|
!!authenticated
|
||||||
end
|
end
|
||||||
|
|
||||||
def totp_enabled?
|
def totp_enabled?
|
||||||
!!(self&.user_second_factor&.enabled?) &&
|
!!(self&.user_second_factors&.totp&.enabled?) &&
|
||||||
!SiteSetting.enable_sso &&
|
!SiteSetting.enable_sso &&
|
||||||
SiteSetting.enable_local_logins
|
SiteSetting.enable_local_logins
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def backup_codes_enabled?
|
||||||
|
!!(self&.user_second_factors&.backup_codes&.present?) &&
|
||||||
|
!SiteSetting.enable_sso &&
|
||||||
|
SiteSetting.enable_local_logins
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_second_factor(token, second_factor_method)
|
||||||
|
if second_factor_method == UserSecondFactor.methods[:totp]
|
||||||
|
authenticate_totp(token)
|
||||||
|
elsif second_factor_method == UserSecondFactor.methods[:backup_codes]
|
||||||
|
authenticate_backup_code(token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_backup_codes
|
||||||
|
codes = []
|
||||||
|
10.times do
|
||||||
|
codes << SecureRandom.hex(8)
|
||||||
|
end
|
||||||
|
|
||||||
|
codes_json = codes.map do |code|
|
||||||
|
salt = SecureRandom.hex(16)
|
||||||
|
{ salt: salt,
|
||||||
|
code_hash: hash_backup_code(code, salt)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.user_second_factors.backup_codes.empty?
|
||||||
|
create_backup_codes(codes_json)
|
||||||
|
else
|
||||||
|
self.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
|
||||||
|
create_backup_codes(codes_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
codes
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_backup_codes(codes)
|
||||||
|
codes.each do |code|
|
||||||
|
UserSecondFactor.create!(
|
||||||
|
user_id: self.id,
|
||||||
|
data: code.to_json,
|
||||||
|
enabled: true,
|
||||||
|
method: UserSecondFactor.methods[:backup_codes]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_backup_code(backup_code)
|
||||||
|
if !backup_code.blank?
|
||||||
|
codes = self&.user_second_factors&.backup_codes
|
||||||
|
|
||||||
|
codes.each do |code|
|
||||||
|
stored_code = JSON.parse(code.data)["code_hash"]
|
||||||
|
stored_salt = JSON.parse(code.data)["salt"]
|
||||||
|
backup_hash = hash_backup_code(backup_code, stored_salt)
|
||||||
|
next unless backup_hash == stored_code
|
||||||
|
|
||||||
|
code.update(enabled: false, last_used: DateTime.now)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def hash_backup_code(code, salt)
|
||||||
|
Pbkdf2.hash_password(code, salt, Rails.configuration.pbkdf2_iterations, Rails.configuration.pbkdf2_algorithm)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -67,7 +67,7 @@ class User < ActiveRecord::Base
|
|||||||
has_one :google_user_info, dependent: :destroy
|
has_one :google_user_info, dependent: :destroy
|
||||||
has_one :oauth2_user_info, dependent: :destroy
|
has_one :oauth2_user_info, dependent: :destroy
|
||||||
has_one :instagram_user_info, dependent: :destroy
|
has_one :instagram_user_info, dependent: :destroy
|
||||||
has_one :user_second_factor, dependent: :destroy
|
has_many :user_second_factors, dependent: :destroy
|
||||||
has_one :user_stat, dependent: :destroy
|
has_one :user_stat, dependent: :destroy
|
||||||
has_one :user_profile, dependent: :destroy, inverse_of: :user
|
has_one :user_profile, dependent: :destroy, inverse_of: :user
|
||||||
has_one :single_sign_on_record, dependent: :destroy
|
has_one :single_sign_on_record, dependent: :destroy
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
class UserSecondFactor < ActiveRecord::Base
|
class UserSecondFactor < ActiveRecord::Base
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
scope :backup_codes, -> { where(method: 2, enabled: true) }
|
||||||
|
|
||||||
def self.methods
|
def self.methods
|
||||||
@methods ||= Enum.new(
|
@methods ||= Enum.new(
|
||||||
totp: 1,
|
totp: 1,
|
||||||
|
backup_codes: 2,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.totp
|
||||||
|
where(method: 1).first
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
@ -73,7 +73,8 @@ class UserSerializer < BasicUserSerializer
|
|||||||
:primary_group_flair_bg_color,
|
:primary_group_flair_bg_color,
|
||||||
:primary_group_flair_color,
|
:primary_group_flair_color,
|
||||||
:staged,
|
:staged,
|
||||||
:second_factor_enabled
|
:second_factor_enabled,
|
||||||
|
:second_factor_backup_enabled
|
||||||
|
|
||||||
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
|
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
|
||||||
has_many :groups, embed: :object, serializer: BasicGroupSerializer
|
has_many :groups, embed: :object, serializer: BasicGroupSerializer
|
||||||
@ -151,6 +152,14 @@ class UserSerializer < BasicUserSerializer
|
|||||||
object.totp_enabled?
|
object.totp_enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_second_factor_backup_enabled?
|
||||||
|
object&.id == scope.user&.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def second_factor_backup_enabled
|
||||||
|
object.backup_codes_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
def can_change_bio
|
def can_change_bio
|
||||||
!(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio)
|
!(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio)
|
||||||
end
|
end
|
||||||
|
2
app/views/common/_second_factor_backup_input.html.erb
Normal file
2
app/views/common/_second_factor_backup_input.html.erb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[a-z0-9]{16}', maxlength: 16, type: 'text') %>
|
||||||
|
<%= hidden_field_tag 'second_factor_method', '2' %>
|
18
app/views/common/_second_factor_form_script.html.erb
Normal file
18
app/views/common/_second_factor_form_script.html.erb
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<%= javascript_tag do %>
|
||||||
|
var useTotp = "<%= t("login.second_factor_toggle.totp") %>";
|
||||||
|
var useBackup = "<%= t("login.second_factor_toggle.backup_code") %>";
|
||||||
|
var backupForm = document.getElementById("backup-second-factor-form");
|
||||||
|
var primaryForm = document.getElementById("primary-second-factor-form");
|
||||||
|
document.getElementById("toggle-form").onclick = function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (backupForm.style.display === "none") {
|
||||||
|
backupForm.style.display = "block";
|
||||||
|
primaryForm.style.display = "none";
|
||||||
|
document.getElementById("toggle-form").innerHTML = useTotp;
|
||||||
|
} else {
|
||||||
|
backupForm.style.display = "none";
|
||||||
|
primaryForm.style.display = "block";
|
||||||
|
document.getElementById("toggle-form").innerHTML = useBackup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<% end %>
|
@ -1 +1,2 @@
|
|||||||
<%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[0-9]{6}', maxlength: 6, type: 'tel') %>
|
<%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[0-9]{6}', maxlength: 6, type: 'tel') %>
|
||||||
|
<%= hidden_field_tag 'second_factor_method', '1' %>
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
<%end%>
|
<%end%>
|
||||||
|
|
||||||
<%if @second_factor_required%>
|
<%if @second_factor_required%>
|
||||||
<div style="display: flex;">
|
<div id="simple-container">
|
||||||
<div style="margin: auto;">
|
<div id="primary-second-factor-form">
|
||||||
<%= form_tag(method: "post") do%>
|
<%= form_tag(method: "post") do%>
|
||||||
<h2><%=t "login.second_factor_title" %></h2>
|
<h2><%=t "login.second_factor_title" %></h2>
|
||||||
<%= label_tag(:second_factor_token, t("login.second_factor_description")) %>
|
<%= label_tag(:second_factor_token, t("login.second_factor_description")) %>
|
||||||
@ -14,9 +14,24 @@
|
|||||||
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %>
|
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %>
|
||||||
<%end%>
|
<%end%>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%if @backup_codes_enabled%>
|
||||||
|
<div id="backup-second-factor-form" style="display: none">
|
||||||
|
<%= form_tag(method: "post") do%>
|
||||||
|
<h2><%=t "login.second_factor_backup_title" %></h2>
|
||||||
|
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
|
||||||
|
<div><%= render 'common/second_factor_backup_input' %></div>
|
||||||
|
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %>
|
||||||
|
<%end%>
|
||||||
|
</div>
|
||||||
|
<a href id="toggle-form"><%=t "login.second_factor_toggle.backup_code" %></a>
|
||||||
|
<%= render 'common/second_factor_form_script' %>
|
||||||
|
<%end%>
|
||||||
</div>
|
</div>
|
||||||
<%end%>
|
<%end%>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<% content_for :title do %><%=t "email_login.title" %><% end %>
|
<% content_for :title do %><%=t "email_login.title" %><% end %>
|
||||||
|
|
||||||
<%- content_for(:no_ember_head) do %>
|
<%- content_for(:no_ember_head) do %>
|
||||||
|
@ -8,11 +8,25 @@
|
|||||||
<% if @error %><p><%= @error %></p><% end %>
|
<% if @error %><p><%= @error %></p><% end %>
|
||||||
|
|
||||||
<% if @second_factor_required %>
|
<% if @second_factor_required %>
|
||||||
<%=form_tag({}, method: :put) do %>
|
<div id="primary-second-factor-form">
|
||||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
<%=form_tag({}, method: :put) do %>
|
||||||
<%= render 'common/second_factor_text_field' %><br><br>
|
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||||
<%= submit_tag t('submit')%>
|
<%= render 'common/second_factor_text_field' %><br><br>
|
||||||
<% end %>
|
<%= submit_tag t('submit')%>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%if @backup_codes_enabled%>
|
||||||
|
<div id="backup-second-factor-form" style="display: none">
|
||||||
|
<%= form_tag({}, method: :put) do%>
|
||||||
|
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
|
||||||
|
<%= render 'common/second_factor_backup_input' %><br><br>
|
||||||
|
<%= submit_tag(t("submit")) %>
|
||||||
|
<%end%>
|
||||||
|
</div>
|
||||||
|
<a href id="toggle-form"><%=t "login.second_factor_backup" %></a>
|
||||||
|
<%= render 'common/second_factor_form_script' %>
|
||||||
|
<%end%>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%=form_tag({}, method: :put) do %>
|
<%=form_tag({}, method: :put) do %>
|
||||||
|
@ -8,16 +8,33 @@
|
|||||||
<br>
|
<br>
|
||||||
<a class="btn" href="/"><%= t('change_email.please_continue', site_name: SiteSetting.title) %></a>
|
<a class="btn" href="/"><%= t('change_email.please_continue', site_name: SiteSetting.title) %></a>
|
||||||
<% elsif @update_result == :invalid_second_factor%>
|
<% elsif @update_result == :invalid_second_factor%>
|
||||||
<h2><%= t('login.second_factor_title') %></h2>
|
<div id="primary-second-factor-form">
|
||||||
<br>
|
<h2><%= t('login.second_factor_title') %></h2>
|
||||||
<%=form_tag({}, method: :put) do %>
|
<br>
|
||||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
<%=form_tag({}, method: :put) do %>
|
||||||
<%= text_field_tag(:second_factor_token, nil, autofocus: true) %><br>
|
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||||
<% if @show_invalid_second_factor_error %>
|
<div><%= render 'common/second_factor_text_field' %></div>
|
||||||
<div class='alert alert-error'><%= t('login.invalid_second_factor_code') %></div>
|
<% if @show_invalid_second_factor_error %>
|
||||||
|
<div class='alert alert-error'><%= t('login.invalid_second_factor_code') %></div>
|
||||||
|
<% end %>
|
||||||
|
<%= submit_tag t('submit'), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= submit_tag t('submit'), class: "btn btn-primary" %>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
<%if @backup_codes_enabled %>
|
||||||
|
<div id="backup-second-factor-form" style="display: none">
|
||||||
|
<h2><%= t('login.second_factor_backup_title') %></h2>
|
||||||
|
<br>
|
||||||
|
<%= form_tag({}, method: :put) do%>
|
||||||
|
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
|
||||||
|
<div><%= render 'common/second_factor_backup_input' %></div>
|
||||||
|
<%= submit_tag(t("submit"), class: "btn btn-primary") %>
|
||||||
|
<%end%>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<a href id="toggle-form"><%=t "login.second_factor_backup" %></a>
|
||||||
|
<%= render 'common/second_factor_form_script' %>
|
||||||
|
<%end%>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class='alert alert-error'>
|
<div class='alert alert-error'>
|
||||||
<%=t 'change_email.already_done' %>
|
<%=t 'change_email.already_done' %>
|
||||||
|
@ -738,6 +738,19 @@ en:
|
|||||||
choose_new: "Choose a new password"
|
choose_new: "Choose a new password"
|
||||||
choose: "Choose a password"
|
choose: "Choose a password"
|
||||||
|
|
||||||
|
second_factor_backup:
|
||||||
|
title: "Two Factor backup code management"
|
||||||
|
regenerate: "Regenerate"
|
||||||
|
disable: "Disable"
|
||||||
|
enable: "Enable"
|
||||||
|
enable_long: "Enable backup codes"
|
||||||
|
manage: "Manage backup codes"
|
||||||
|
copied_to_clipboard: "Copied to Clipboard"
|
||||||
|
copy_to_clipboard_error: "Error copying data to Clipboard"
|
||||||
|
codes:
|
||||||
|
title: "Backup codes"
|
||||||
|
description: "Each line is a different backup code which can only be used once. It's recommended to save this file in a safe place."
|
||||||
|
|
||||||
second_factor:
|
second_factor:
|
||||||
title: "Two Factor Authentication"
|
title: "Two Factor Authentication"
|
||||||
disable: "Disable two factor authentication"
|
disable: "Disable two factor authentication"
|
||||||
@ -1144,6 +1157,10 @@ en:
|
|||||||
password: "Password"
|
password: "Password"
|
||||||
second_factor_title: "Two Factor Authentication"
|
second_factor_title: "Two Factor Authentication"
|
||||||
second_factor_description: "Please enter the authentication code from your app:"
|
second_factor_description: "Please enter the authentication code from your app:"
|
||||||
|
second_factor_backup: "<a href>Log in using a backup code</a>"
|
||||||
|
second_factor_backup_title: "Two Factor Backup"
|
||||||
|
second_factor_backup_description: "Please enter one of your backup codes:"
|
||||||
|
second_factor: "<a href>Log in using Authenticator app</a>"
|
||||||
email_placeholder: "email or username"
|
email_placeholder: "email or username"
|
||||||
caps_lock_warning: "Caps Lock is on"
|
caps_lock_warning: "Caps Lock is on"
|
||||||
error: "Unknown error"
|
error: "Unknown error"
|
||||||
|
@ -1892,7 +1892,12 @@ en:
|
|||||||
already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again."
|
already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again."
|
||||||
second_factor_title: "Two Factor Authentication"
|
second_factor_title: "Two Factor Authentication"
|
||||||
second_factor_description: "Please enter the required authentication code from your app:"
|
second_factor_description: "Please enter the required authentication code from your app:"
|
||||||
invalid_second_factor_code: "Invalid authentication code"
|
second_factor_backup_description: "Please enter one of your backup codes:"
|
||||||
|
second_factor_backup_title: "Two Factor Backup"
|
||||||
|
invalid_second_factor_code: "Invalid authentication code. Each code can only be used once."
|
||||||
|
second_factor_toggle:
|
||||||
|
totp: "Log in using Authenticator app"
|
||||||
|
backup_code: "Log in using a backup code"
|
||||||
|
|
||||||
user:
|
user:
|
||||||
no_accounts_associated: "No accounts associated"
|
no_accounts_associated: "No accounts associated"
|
||||||
|
@ -345,6 +345,8 @@ Discourse::Application.routes.draw do
|
|||||||
post "#{root_path}/second_factors" => "users#create_second_factor"
|
post "#{root_path}/second_factors" => "users#create_second_factor"
|
||||||
put "#{root_path}/second_factor" => "users#update_second_factor"
|
put "#{root_path}/second_factor" => "users#update_second_factor"
|
||||||
|
|
||||||
|
put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup"
|
||||||
|
|
||||||
put "#{root_path}/update-activation-email" => "users#update_activation_email"
|
put "#{root_path}/update-activation-email" => "users#update_activation_email"
|
||||||
get "#{root_path}/hp" => "users#get_honeypot_value"
|
get "#{root_path}/hp" => "users#get_honeypot_value"
|
||||||
post "#{root_path}/email-login" => "users#email_login"
|
post "#{root_path}/email-login" => "users#email_login"
|
||||||
@ -400,6 +402,7 @@ Discourse::Application.routes.draw do
|
|||||||
get "#{root_path}/:username/preferences/username" => "users#preferences", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/preferences/username" => "users#preferences", constraints: { username: RouteFormat.username }
|
||||||
put "#{root_path}/:username/preferences/username" => "users#username", constraints: { username: RouteFormat.username }
|
put "#{root_path}/:username/preferences/username" => "users#username", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/preferences/second-factor" => "users#preferences", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/preferences/second-factor" => "users#preferences", constraints: { username: RouteFormat.username }
|
||||||
|
get "#{root_path}/:username/preferences/second-factor-backup" => "users#preferences", constraints: { username: RouteFormat.username }
|
||||||
delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username }
|
delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username }
|
||||||
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
|
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
|
||||||
|
@ -63,7 +63,7 @@ class AdminUserIndexQuery
|
|||||||
if params[:stats].present? && params[:stats] == false
|
if params[:stats].present? && params[:stats] == false
|
||||||
klass.order(order.reject(&:blank?).join(","))
|
klass.order(order.reject(&:blank?).join(","))
|
||||||
else
|
else
|
||||||
klass.includes(:user_stat, :user_second_factor)
|
klass.includes(:user_stat, :user_second_factors)
|
||||||
.order(order.reject(&:blank?).join(","))
|
.order(order.reject(&:blank?).join(","))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe SecondFactorManager do
|
RSpec.describe SecondFactorManager do
|
||||||
let(:user_second_factor) { Fabricate(:user_second_factor) }
|
let(:user_second_factor_totp) { Fabricate(:user_second_factor_totp) }
|
||||||
let(:user) { user_second_factor.user }
|
let(:user) { user_second_factor_totp.user }
|
||||||
let(:another_user) { Fabricate(:user) }
|
let(:another_user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
let(:user_second_factor_backup) { Fabricate(:user_second_factor_backup) }
|
||||||
|
let(:user_backup) { user_second_factor_backup.user }
|
||||||
|
|
||||||
describe '#totp' do
|
describe '#totp' do
|
||||||
it 'should return the right data' do
|
it 'should return the right data' do
|
||||||
totp = nil
|
totp = nil
|
||||||
@ -14,7 +17,7 @@ RSpec.describe SecondFactorManager do
|
|||||||
end.to change { UserSecondFactor.count }.by(1)
|
end.to change { UserSecondFactor.count }.by(1)
|
||||||
|
|
||||||
expect(totp.issuer).to eq(SiteSetting.title)
|
expect(totp.issuer).to eq(SiteSetting.title)
|
||||||
expect(totp.secret).to eq(another_user.reload.user_second_factor.data)
|
expect(totp.secret).to eq(another_user.reload.user_second_factors.totp.data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -37,7 +40,7 @@ RSpec.describe SecondFactorManager do
|
|||||||
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.totp_provisioning_uri).to eq(
|
||||||
"otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor.data}&issuer=#{SiteSetting.title}"
|
"otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor_totp.data}&issuer=#{SiteSetting.title}"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -45,12 +48,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_factor.last_used).to eq(nil)
|
expect(user.user_second_factors.totp.last_used).to eq(nil)
|
||||||
|
|
||||||
token = user.totp.now
|
token = user.totp.now
|
||||||
|
|
||||||
expect(user.authenticate_totp(token)).to eq(true)
|
expect(user.authenticate_totp(token)).to eq(true)
|
||||||
expect(user.user_second_factor.last_used).to eq(DateTime.now)
|
expect(user.user_second_factors.totp.last_used).to eq(DateTime.now)
|
||||||
expect(user.authenticate_totp(token)).to eq(false)
|
expect(user.authenticate_totp(token)).to eq(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -58,14 +61,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_factor.last_used).to eq(nil)
|
expect(user.user_second_factors.totp.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_factor.last_used).to eq(nil)
|
expect(user.user_second_factors.totp.last_used).to eq(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -79,7 +82,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_factor.update!(enabled: false)
|
user.user_second_factors.totp.update!(enabled: false)
|
||||||
expect(user.totp_enabled?).to eq(false)
|
expect(user.totp_enabled?).to eq(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -107,4 +110,85 @@ RSpec.describe SecondFactorManager do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'backup codes' do
|
||||||
|
describe '#generate_backup_codes' do
|
||||||
|
it 'should generate and store 10 backup codes' do
|
||||||
|
backup_codes = user.generate_backup_codes
|
||||||
|
|
||||||
|
expect(backup_codes.length).to be 10
|
||||||
|
expect(user_backup.user_second_factors.backup_codes).to be_present
|
||||||
|
expect(user_backup.user_second_factors.backup_codes.pluck(:method).uniq[0]).to eq(UserSecondFactor.methods[:backup_codes])
|
||||||
|
expect(user_backup.user_second_factors.backup_codes.pluck(:enabled).uniq[0]).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#create_backup_codes' do
|
||||||
|
it 'should create 10 backup code records' do
|
||||||
|
raw_codes = Array.new(10) { SecureRandom.hex(8) }
|
||||||
|
backup_codes = another_user.create_backup_codes(raw_codes)
|
||||||
|
|
||||||
|
expect(another_user.user_second_factors.backup_codes.length).to be 10
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#authenticate_backup_code' do
|
||||||
|
it 'should be able to authenticate a backup code' do
|
||||||
|
backup_code = "iAmValidBackupCode"
|
||||||
|
|
||||||
|
expect(user_backup.authenticate_backup_code(backup_code)).to eq(true)
|
||||||
|
expect(user_backup.authenticate_backup_code(backup_code)).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when code is blank' do
|
||||||
|
it 'should be false' do
|
||||||
|
expect(user_backup.authenticate_backup_code(nil)).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when code is invalid' do
|
||||||
|
it 'should be false' do
|
||||||
|
expect(user_backup.authenticate_backup_code("notValidBackupCode")).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#backup_codes_enabled?' do
|
||||||
|
describe 'when user does not have a second factor backup enabled' do
|
||||||
|
it 'should return false' do
|
||||||
|
expect(another_user.backup_codes_enabled?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when user's second factor backup codes have been used" do
|
||||||
|
it 'should return false' do
|
||||||
|
user_backup.user_second_factors.backup_codes.update_all(enabled: false)
|
||||||
|
expect(user_backup.backup_codes_enabled?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when user's second factor code is available" do
|
||||||
|
it 'should return true' do
|
||||||
|
expect(user_backup.backup_codes_enabled?).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when SSO is enabled' do
|
||||||
|
it 'should return false' do
|
||||||
|
SiteSetting.sso_url = 'http://someurl.com'
|
||||||
|
SiteSetting.enable_sso = true
|
||||||
|
|
||||||
|
expect(user_backup.backup_codes_enabled?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when local login is disabled' do
|
||||||
|
it 'should return false' do
|
||||||
|
SiteSetting.enable_local_logins = false
|
||||||
|
|
||||||
|
expect(user_backup.backup_codes_enabled?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
Fabricator(:user_second_factor) do
|
Fabricator(:user_second_factor_totp, from: :user_second_factor) do
|
||||||
user
|
user
|
||||||
data 'rcyryaqage3jexfj'
|
data 'rcyryaqage3jexfj'
|
||||||
enabled true
|
enabled true
|
||||||
method UserSecondFactor.methods[:totp]
|
method UserSecondFactor.methods[:totp]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Fabricator(:user_second_factor_backup, from: :user_second_factor) do
|
||||||
|
user
|
||||||
|
# backup code: iAmValidBackupCode
|
||||||
|
data '{"salt":"e84ab3842f173967ca85ca6f5639b7ab","code_hash":"6abfe07527e2f7db45980cf67b9b4bfc7fbeea2685b07dcc3bf49f21349707f3"}'
|
||||||
|
enabled true
|
||||||
|
method UserSecondFactor.methods[:backup_codes]
|
||||||
|
end
|
||||||
|
@ -4,6 +4,7 @@ RSpec.describe UserSecondFactor do
|
|||||||
describe '.methods' do
|
describe '.methods' do
|
||||||
it 'should retain the right order' do
|
it 'should retain the right order' do
|
||||||
expect(described_class.methods[:totp]).to eq(1)
|
expect(described_class.methods[:totp]).to eq(1)
|
||||||
|
expect(described_class.methods[:backup_codes]).to eq(2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -808,12 +808,14 @@ RSpec.describe Admin::UsersController do
|
|||||||
|
|
||||||
describe '#disable_second_factor' do
|
describe '#disable_second_factor' do
|
||||||
let(:second_factor) { user.create_totp }
|
let(:second_factor) { user.create_totp }
|
||||||
|
let(:second_factor_backup) { user.generate_backup_codes }
|
||||||
|
|
||||||
describe 'as an admin' do
|
describe 'as an admin' do
|
||||||
before do
|
before do
|
||||||
sign_in(admin)
|
sign_in(admin)
|
||||||
second_factor
|
second_factor
|
||||||
expect(user.reload.user_second_factor).to eq(second_factor)
|
second_factor_backup
|
||||||
|
expect(user.reload.user_second_factors.totp).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
|
||||||
@ -822,7 +824,7 @@ RSpec.describe Admin::UsersController do
|
|||||||
end.to change { Jobs::CriticalUserEmail.jobs.length }.by(1)
|
end.to change { Jobs::CriticalUserEmail.jobs.length }.by(1)
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(user.reload.user_second_factor).to eq(nil)
|
expect(user.reload.user_second_factors).to be_empty
|
||||||
|
|
||||||
job_args = Jobs::CriticalUserEmail.jobs.first["args"].first
|
job_args = Jobs::CriticalUserEmail.jobs.first["args"].first
|
||||||
|
|
||||||
@ -838,7 +840,7 @@ RSpec.describe Admin::UsersController do
|
|||||||
|
|
||||||
describe 'when user does not have second factor enabled' do
|
describe 'when user does not have second factor enabled' do
|
||||||
it 'should raise the right error' do
|
it 'should raise the right error' do
|
||||||
user.user_second_factor.destroy!
|
user.user_second_factors.destroy_all
|
||||||
|
|
||||||
put "/admin/users/#{user.id}/disable_second_factor.json"
|
put "/admin/users/#{user.id}/disable_second_factor.json"
|
||||||
|
|
||||||
|
@ -146,7 +146,8 @@ RSpec.describe SessionController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'user has 2-factor logins' do
|
context 'user has 2-factor logins' do
|
||||||
let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) }
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
|
||||||
|
|
||||||
describe 'requires second factor' do
|
describe 'requires second factor' do
|
||||||
it 'should return a second factor prompt' do
|
it 'should return a second factor prompt' do
|
||||||
@ -167,24 +168,55 @@ RSpec.describe SessionController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'errors on incorrect 2-factor' do
|
describe 'errors on incorrect 2-factor' do
|
||||||
it 'does not log in with incorrect two factor' do
|
context 'when using totp method' do
|
||||||
post "/session/email-login/#{email_token.token}", params: { second_factor_token: "0000" }
|
it 'does not log in with incorrect two factor' do
|
||||||
|
post "/session/email-login/#{email_token.token}", params: {
|
||||||
|
second_factor_token: "0000",
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
|
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
|
||||||
"login.invalid_second_factor_code"
|
"login.invalid_second_factor_code"
|
||||||
))
|
))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
context 'when using backup code method' do
|
||||||
|
it 'does not log in with incorrect backup code' do
|
||||||
|
post "/session/email-login/#{email_token.token}", params: {
|
||||||
|
second_factor_token: "0000",
|
||||||
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
|
||||||
|
"login.invalid_second_factor_code"
|
||||||
|
))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'allows successful 2-factor' do
|
describe 'allows successful 2-factor' do
|
||||||
it 'logs in correctly' do
|
context 'when using totp method' do
|
||||||
post "/session/email-login/#{email_token.token}", params: {
|
it 'logs in correctly' do
|
||||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
post "/session/email-login/#{email_token.token}", params: {
|
||||||
}
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response).to redirect_to("/")
|
expect(response).to redirect_to("/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
context 'when using backup code method' do
|
||||||
|
it 'logs in correctly' do
|
||||||
|
post "/session/email-login/#{email_token.token}", params: {
|
||||||
|
second_factor_token: "iAmValidBackupCode",
|
||||||
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to redirect_to("/")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -899,7 +931,8 @@ RSpec.describe SessionController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when user has 2-factor logins' do
|
context 'when user has 2-factor logins' do
|
||||||
let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) }
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
|
||||||
|
|
||||||
describe 'when second factor token is missing' do
|
describe 'when second factor token is missing' do
|
||||||
it 'should return the right response' do
|
it 'should return the right response' do
|
||||||
@ -916,35 +949,74 @@ RSpec.describe SessionController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'when second factor token is invalid' do
|
describe 'when second factor token is invalid' do
|
||||||
it 'should return the right response' do
|
context 'when using totp method' do
|
||||||
post "/session.json", params: {
|
it 'should return the right response' do
|
||||||
login: user.username,
|
post "/session.json", params: {
|
||||||
password: 'myawesomepassword',
|
login: user.username,
|
||||||
second_factor_token: '00000000'
|
password: 'myawesomepassword',
|
||||||
}
|
second_factor_token: '00000000',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
|
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
|
||||||
'login.invalid_second_factor_code'
|
'login.invalid_second_factor_code'
|
||||||
))
|
))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
context 'when using backup code method' do
|
||||||
|
it 'should return the right response' do
|
||||||
|
post "/session.json", params: {
|
||||||
|
login: user.username,
|
||||||
|
password: 'myawesomepassword',
|
||||||
|
second_factor_token: '00000000',
|
||||||
|
second_factor_method: 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
|
end
|
||||||
|
|
||||||
describe 'when second factor token is valid' do
|
describe 'when second factor token is valid' do
|
||||||
it 'should log the user in' do
|
context 'when using totp method' do
|
||||||
post "/session.json", params: {
|
it 'should log the user in' do
|
||||||
login: user.username,
|
post "/session.json", params: {
|
||||||
password: 'myawesomepassword',
|
login: user.username,
|
||||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
password: 'myawesomepassword',
|
||||||
}
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||||
expect(response.status).to eq(200)
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
user.reload
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
user.reload
|
||||||
|
|
||||||
expect(session[:current_user_id]).to eq(user.id)
|
expect(session[:current_user_id]).to eq(user.id)
|
||||||
expect(user.user_auth_tokens.count).to eq(1)
|
expect(user.user_auth_tokens.count).to eq(1)
|
||||||
|
|
||||||
expect(UserAuthToken.hash_token(cookies[:_t]))
|
expect(UserAuthToken.hash_token(cookies[:_t]))
|
||||||
.to eq(user.user_auth_tokens.first.auth_token)
|
.to eq(user.user_auth_tokens.first.auth_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
context 'when using backup code method' do
|
||||||
|
it 'should log the user in' do
|
||||||
|
post "/session.json", params: {
|
||||||
|
login: user.username,
|
||||||
|
password: 'myawesomepassword',
|
||||||
|
second_factor_token: 'iAmValidBackupCode',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
user.reload
|
||||||
|
|
||||||
|
expect(session[:current_user_id]).to eq(user.id)
|
||||||
|
expect(user.user_auth_tokens.count).to eq(1)
|
||||||
|
|
||||||
|
expect(UserAuthToken.hash_token(cookies[:_t]))
|
||||||
|
.to eq(user.user_auth_tokens.first.auth_token)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -190,7 +190,7 @@ describe UsersController do
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}')
|
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"backup_enabled":false}')
|
||||||
|
|
||||||
expect(session["password-#{token}"]).to be_blank
|
expect(session["password-#{token}"]).to be_blank
|
||||||
expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0)
|
expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0)
|
||||||
@ -248,16 +248,20 @@ describe UsersController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context '2 factor authentication required' do
|
context '2 factor authentication required' do
|
||||||
let!(:second_factor) { Fabricate(:user_second_factor, user: user) }
|
let!(:second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
|
||||||
it 'does not change with an invalid token' do
|
it 'does not change with an invalid token' do
|
||||||
token = user.email_tokens.create!(email: user.email).token
|
token = user.email_tokens.create!(email: user.email).token
|
||||||
|
|
||||||
get "/u/password-reset/#{token}"
|
get "/u/password-reset/#{token}"
|
||||||
|
|
||||||
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true}')
|
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"backup_enabled":false}')
|
||||||
|
|
||||||
put "/u/password-reset/#{token}", params: { password: 'hg9ow8yHG32O', second_factor_token: '000000' }
|
put "/u/password-reset/#{token}", params: {
|
||||||
|
password: 'hg9ow8yHG32O',
|
||||||
|
second_factor_token: '000000',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response.body).to include(I18n.t("login.invalid_second_factor_code"))
|
expect(response.body).to include(I18n.t("login.invalid_second_factor_code"))
|
||||||
|
|
||||||
@ -273,7 +277,8 @@ describe UsersController do
|
|||||||
|
|
||||||
put "/u/password-reset/#{token}", params: {
|
put "/u/password-reset/#{token}", params: {
|
||||||
password: 'hg9ow8yHG32O',
|
password: 'hg9ow8yHG32O',
|
||||||
second_factor_token: ROTP::TOTP.new(second_factor.data).now
|
second_factor_token: ROTP::TOTP.new(second_factor.data).now,
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
}
|
}
|
||||||
|
|
||||||
user.reload
|
user.reload
|
||||||
@ -400,7 +405,7 @@ describe UsersController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'when 2 factor authentication is enabled' do
|
describe 'when 2 factor authentication is enabled' do
|
||||||
let(:second_factor) { Fabricate(:user_second_factor, user: admin) }
|
let(:second_factor) { Fabricate(:user_second_factor_totp, user: admin) }
|
||||||
let(:email_token) { Fabricate(:email_token, user: admin) }
|
let(:email_token) { Fabricate(:email_token, user: admin) }
|
||||||
|
|
||||||
it 'does not log in when token required' do
|
it 'does not log in when token required' do
|
||||||
@ -415,7 +420,10 @@ describe UsersController do
|
|||||||
it 'should display the right error' do
|
it 'should display the right error' do
|
||||||
second_factor
|
second_factor
|
||||||
|
|
||||||
put "/u/admin-login/#{email_token.token}", params: { second_factor_token: '13213' }
|
put "/u/admin-login/#{email_token.token}", params: {
|
||||||
|
second_factor_token: '13213',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(response.body).to include(I18n.t('login.second_factor_description'));
|
expect(response.body).to include(I18n.t('login.second_factor_description'));
|
||||||
@ -424,7 +432,10 @@ describe UsersController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'logs in when a valid 2-factor token is given' do
|
it 'logs in when a valid 2-factor token is given' do
|
||||||
put "/u/admin-login/#{email_token.token}", params: { second_factor_token: ROTP::TOTP.new(second_factor.data).now }
|
put "/u/admin-login/#{email_token.token}", params: {
|
||||||
|
second_factor_token: ROTP::TOTP.new(second_factor.data).now,
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
|
}
|
||||||
|
|
||||||
expect(response).to redirect_to('/')
|
expect(response).to redirect_to('/')
|
||||||
expect(session[:current_user_id]).to eq(admin.id)
|
expect(session[:current_user_id]).to eq(admin.id)
|
||||||
@ -2795,7 +2806,7 @@ describe UsersController do
|
|||||||
|
|
||||||
it 'succeeds on correct password' do
|
it 'succeeds on correct password' do
|
||||||
user.create_totp
|
user.create_totp
|
||||||
user.user_second_factor.update!(data: "abcdefghijklmnop")
|
user.user_second_factors.totp.update!(data: "abcdefghijklmnop")
|
||||||
|
|
||||||
post "/users/second_factors.json", params: {
|
post "/users/second_factors.json", params: {
|
||||||
password: 'myawesomepassword'
|
password: 'myawesomepassword'
|
||||||
@ -2816,12 +2827,13 @@ describe UsersController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#update_second_factor' do
|
describe '#update_second_factor' do
|
||||||
let(:user_second_factor) { Fabricate(:user_second_factor, user: user) }
|
let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
|
||||||
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", params: {
|
||||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
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)
|
||||||
@ -2849,6 +2861,7 @@ describe UsersController 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: {
|
||||||
second_factor_token: '000000',
|
second_factor_token: '000000',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp],
|
||||||
enable: 'true',
|
enable: 'true',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2865,22 +2878,136 @@ describe UsersController 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_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||||
enable: 'true',
|
enable: 'true',
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(user.reload.user_second_factor.enabled).to be true
|
expect(user.reload.user_second_factors.totp.enabled).to be true
|
||||||
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,
|
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)
|
||||||
expect(user.reload.user_second_factor).to eq(nil)
|
expect(user.reload.user_second_factors.totp).to eq(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when user is updating backup codes" do
|
||||||
|
context 'when token is missing' do
|
||||||
|
it 'returns the right response' do
|
||||||
|
put "/users/second_factor.json", params: {
|
||||||
|
second_factor_method: UserSecondFactor.methods[:backup_codes],
|
||||||
|
}
|
||||||
|
|
||||||
|
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[: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
|
||||||
|
|
||||||
|
context 'when token is valid' do
|
||||||
|
it 'should allow second factor backup for the user to be disabled' do
|
||||||
|
put "/users/second_factor.json", params: {
|
||||||
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||||
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(user.reload.user_second_factors.backup_codes).to be_empty
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#create_second_factor_backup' do
|
||||||
|
let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
|
||||||
|
context 'when not logged in' do
|
||||||
|
it 'should return the right response' do
|
||||||
|
put "/users/second_factors_backup.json", params: {
|
||||||
|
second_factor_token: 'wrongtoken'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when logged in' do
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'create 2fa request' do
|
||||||
|
it 'fails on incorrect password' do
|
||||||
|
put "/users/second_factors_backup.json", params: {
|
||||||
|
second_factor_token: 'wrongtoken'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
|
||||||
|
"login.invalid_second_factor_code")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when local logins are disabled' do
|
||||||
|
it 'should return the right response' do
|
||||||
|
SiteSetting.enable_local_logins = false
|
||||||
|
|
||||||
|
put "/users/second_factors_backup.json", params: {
|
||||||
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when SSO is enabled' do
|
||||||
|
it 'should return the right response' do
|
||||||
|
SiteSetting.sso_url = 'http://someurl.com'
|
||||||
|
SiteSetting.enable_sso = true
|
||||||
|
|
||||||
|
put "/users/second_factors_backup.json", params: {
|
||||||
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'succeeds on correct password' do
|
||||||
|
user_second_factor
|
||||||
|
|
||||||
|
put "/users/second_factors_backup.json", params: {
|
||||||
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
|
||||||
|
expect(response_body['backup_codes'].length).to be(10)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -71,7 +71,7 @@ describe UsersEmailController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'second factor required' do
|
context 'second factor required' do
|
||||||
let!(:second_factor) { Fabricate(:user_second_factor, user: user) }
|
let!(:second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
||||||
|
|
||||||
it 'requires a second factor token' do
|
it 'requires a second factor token' do
|
||||||
get "/u/authorize-email/#{user.email_tokens.last.token}"
|
get "/u/authorize-email/#{user.email_tokens.last.token}"
|
||||||
@ -86,7 +86,8 @@ describe UsersEmailController do
|
|||||||
|
|
||||||
it 'adds an error on a second factor attempt' do
|
it 'adds an error on a second factor attempt' do
|
||||||
get "/u/authorize-email/#{user.email_tokens.last.token}", params: {
|
get "/u/authorize-email/#{user.email_tokens.last.token}", params: {
|
||||||
second_factor_token: "000000"
|
second_factor_token: "000000",
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
@ -95,7 +96,8 @@ describe UsersEmailController do
|
|||||||
|
|
||||||
it 'confirms with a correct second token' do
|
it 'confirms with a correct second token' do
|
||||||
get "/u/authorize-email/#{user.email_tokens.last.token}", params: {
|
get "/u/authorize-email/#{user.email_tokens.last.token}", params: {
|
||||||
second_factor_token: ROTP::TOTP.new(second_factor.data).now
|
second_factor_token: ROTP::TOTP.new(second_factor.data).now,
|
||||||
|
second_factor_method: UserSecondFactor.methods[:totp]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
@ -988,7 +988,7 @@ describe UserMerger do
|
|||||||
it "deletes auth tokens" do
|
it "deletes auth tokens" do
|
||||||
Fabricate(:api_key, user: source_user)
|
Fabricate(:api_key, user: source_user)
|
||||||
Fabricate(:readonly_user_api_key, user: source_user)
|
Fabricate(:readonly_user_api_key, user: source_user)
|
||||||
Fabricate(:user_second_factor, user: source_user)
|
Fabricate(:user_second_factor_totp, user: source_user)
|
||||||
|
|
||||||
SiteSetting.verbose_auth_token_logging = true
|
SiteSetting.verbose_auth_token_logging = true
|
||||||
UserAuthToken.generate!(user_id: source_user.id, user_agent: "Firefox", client_ip: "127.0.0.1")
|
UserAuthToken.generate!(user_id: source_user.id, user_agent: "Firefox", client_ip: "127.0.0.1")
|
||||||
|
@ -51,7 +51,7 @@ acceptance("Password Reset", {
|
|||||||
return response({
|
return response({
|
||||||
success: false,
|
success: false,
|
||||||
message: "invalid token",
|
message: "invalid token",
|
||||||
errors: { user_second_factor: ["invalid token"] }
|
errors: { user_second_factors: ["invalid token"] }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -114,7 +114,7 @@ QUnit.test("Password Reset Page With Second Factor", assert => {
|
|||||||
assert.ok(exists("#second-factor"), "shows the second factor prompt");
|
assert.ok(exists("#second-factor"), "shows the second factor prompt");
|
||||||
});
|
});
|
||||||
|
|
||||||
fillIn("#second-factor", "0000");
|
fillIn("input#second-factor", "0000");
|
||||||
click(".password-reset form button");
|
click(".password-reset form button");
|
||||||
|
|
||||||
andThen(() => {
|
andThen(() => {
|
||||||
@ -128,7 +128,7 @@ QUnit.test("Password Reset Page With Second Factor", assert => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
fillIn("#second-factor", "123123");
|
fillIn("input#second-factor", "123123");
|
||||||
click(".password-reset form button");
|
click(".password-reset form button");
|
||||||
|
|
||||||
andThen(() => {
|
andThen(() => {
|
||||||
|
@ -18,6 +18,11 @@ acceptance("User Preferences", {
|
|||||||
server.put("/u/second_factor.json", () => { //eslint-disable-line
|
server.put("/u/second_factor.json", () => { //eslint-disable-line
|
||||||
return response({ error: "invalid token" });
|
return response({ error: "invalid token" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
server.put("/u/second_factors_backup.json", () => { //eslint-disable-line
|
||||||
|
return response({ backup_codes: ["dsffdsd", "fdfdfdsf", "fddsds"] });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,6 +145,24 @@ QUnit.test("second factor", assert => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QUnit.test("second factor backup", assert => {
|
||||||
|
visit("/u/eviltrout/preferences/second-factor-backup");
|
||||||
|
|
||||||
|
andThen(() => {
|
||||||
|
assert.ok(
|
||||||
|
exists("#second-factor-token"),
|
||||||
|
"it has a authentication token input"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
fillIn("#second-factor-token", "111111");
|
||||||
|
click(".second-factor-form .btn-primary");
|
||||||
|
|
||||||
|
andThen(() => {
|
||||||
|
assert.ok(exists(".backup-codes-area"), "shows backup codes");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
acceptance("User Preferences when badges are disabled", {
|
acceptance("User Preferences when badges are disabled", {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
settings: {
|
settings: {
|
||||||
|
@ -180,3 +180,39 @@ QUnit.test("create account", assert => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QUnit.test("second factor backup - valid token", assert => {
|
||||||
|
visit("/");
|
||||||
|
click("header .login-button");
|
||||||
|
fillIn("#login-account-name", "eviltrout");
|
||||||
|
fillIn("#login-account-password", "need-second-factor");
|
||||||
|
click(".modal-footer .btn-primary");
|
||||||
|
click(".login-modal .toggle-second-factor-method");
|
||||||
|
fillIn("#login-second-factor", "123456");
|
||||||
|
click(".modal-footer .btn-primary");
|
||||||
|
|
||||||
|
andThen(() => {
|
||||||
|
assert.ok(
|
||||||
|
exists(".modal-footer .btn-primary:disabled"),
|
||||||
|
"it closes the modal when the code is valid"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
QUnit.test("second factor backup - invalid token", assert => {
|
||||||
|
visit("/");
|
||||||
|
click("header .login-button");
|
||||||
|
fillIn("#login-account-name", "eviltrout");
|
||||||
|
fillIn("#login-account-password", "need-second-factor");
|
||||||
|
click(".modal-footer .btn-primary");
|
||||||
|
click(".login-modal .toggle-second-factor-method");
|
||||||
|
fillIn("#login-second-factor", "something");
|
||||||
|
click(".modal-footer .btn-primary");
|
||||||
|
|
||||||
|
andThen(() => {
|
||||||
|
assert.ok(
|
||||||
|
exists("#modal-alert:visible"),
|
||||||
|
"it shows an error when the code is invalid"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -252,13 +252,14 @@ export default function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.password === "need-second-factor") {
|
if (data.password === "need-second-factor") {
|
||||||
if (data.second_factor_token) {
|
if (data.second_factor_token && data.second_factor_token === "123456") {
|
||||||
return response({ username: "eviltrout" });
|
return response({ username: "eviltrout" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return response({
|
return response({
|
||||||
error: "Invalid Second Factor",
|
error: "Invalid Second Factor",
|
||||||
reason: "invalid_second_factor",
|
reason: "invalid_second_factor",
|
||||||
|
backup_enabled: true,
|
||||||
sent_to_email: "eviltrout@example.com",
|
sent_to_email: "eviltrout@example.com",
|
||||||
current_email: "current@example.com"
|
current_email: "current@example.com"
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user