mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 08:57:10 -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 { extractError } from "discourse/lib/ajax-error";
|
||||
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
|
||||
const AuthErrors = [
|
||||
@ -31,6 +32,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
canLoginLocal: setting("enable_local_logins"),
|
||||
canLoginLocalWithEmail: setting("enable_local_logins_via_email"),
|
||||
loginRequired: Em.computed.alias("application.loginRequired"),
|
||||
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
|
||||
|
||||
resetForm: function() {
|
||||
this.setProperties({
|
||||
@ -103,14 +105,14 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
data: {
|
||||
login: this.get("loginName"),
|
||||
password: this.get("loginPassword"),
|
||||
second_factor_token: this.get("loginSecondFactor")
|
||||
second_factor_token: this.get("loginSecondFactor"),
|
||||
second_factor_method: this.get("secondFactorMethod")
|
||||
}
|
||||
}).then(
|
||||
function(result) {
|
||||
// Successful login
|
||||
if (result && result.error) {
|
||||
self.set("loggingIn", false);
|
||||
|
||||
if (
|
||||
result.reason === "invalid_second_factor" &&
|
||||
!self.get("secondFactorRequired")
|
||||
@ -118,7 +120,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
$("#modal-alert").hide();
|
||||
self.setProperties({
|
||||
secondFactorRequired: true,
|
||||
showLoginButtons: false
|
||||
showLoginButtons: false,
|
||||
backupEnabled: result.backup_enabled
|
||||
});
|
||||
|
||||
$("#credentials").hide();
|
||||
|
@ -4,11 +4,14 @@ import DiscourseURL from "discourse/lib/url";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import PasswordValidation from "discourse/mixins/password-validation";
|
||||
import { userPath } from "discourse/lib/url";
|
||||
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
|
||||
|
||||
export default Ember.Controller.extend(PasswordValidation, {
|
||||
isDeveloper: Ember.computed.alias("model.is_developer"),
|
||||
admin: Ember.computed.alias("model.admin"),
|
||||
secondFactorRequired: Ember.computed.alias("model.second_factor_required"),
|
||||
backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"),
|
||||
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
|
||||
passwordRequired: true,
|
||||
errorMessage: null,
|
||||
successMessage: null,
|
||||
@ -36,7 +39,8 @@ export default Ember.Controller.extend(PasswordValidation, {
|
||||
type: "PUT",
|
||||
data: {
|
||||
password: this.get("accountPassword"),
|
||||
second_factor_token: this.get("secondFactor")
|
||||
second_factor_token: this.get("secondFactor"),
|
||||
second_factor_method: this.get("secondFactorMethod")
|
||||
}
|
||||
})
|
||||
.then(result => {
|
||||
@ -50,7 +54,7 @@ export default Ember.Controller.extend(PasswordValidation, {
|
||||
DiscourseURL.redirectTo(result.redirect_to || "/");
|
||||
}
|
||||
} else {
|
||||
if (result.errors && result.errors.user_second_factor) {
|
||||
if (result.errors && result.errors.user_second_factors) {
|
||||
this.setProperties({
|
||||
secondFactorRequired: true,
|
||||
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.get("content")
|
||||
.toggleSecondFactor(this.get("secondFactorToken"), enable)
|
||||
.toggleSecondFactor(this.get("secondFactorToken"), enable, 1)
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
this.set("errorMessage", response.error);
|
||||
|
@ -19,6 +19,8 @@ import PreloadStore from "preload-store";
|
||||
import { defaultHomepage } from "discourse/lib/utilities";
|
||||
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 User = RestModel.extend({
|
||||
@ -352,9 +354,20 @@ const User = RestModel.extend({
|
||||
});
|
||||
},
|
||||
|
||||
toggleSecondFactor(token, enable) {
|
||||
toggleSecondFactor(token, enable, method) {
|
||||
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"
|
||||
});
|
||||
},
|
||||
|
@ -156,6 +156,7 @@ export default function() {
|
||||
this.route("username");
|
||||
this.route("email");
|
||||
this.route("second-factor");
|
||||
this.route("second-factor-backup");
|
||||
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;">
|
||||
<h3>{{i18n 'login.second_factor_title'}}</h3>
|
||||
<p>{{i18n 'login.second_factor_description'}}</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
{{yield}}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div id="second-factor">
|
||||
<h3>{{secondFactorTitle}}</h3>
|
||||
<p>{{secondFactorDescription}}</p>
|
||||
{{yield}}
|
||||
{{#if backupEnabled}}
|
||||
<p>
|
||||
{{discourse-linked-text
|
||||
class="toggle-second-factor-method"
|
||||
action="toggleSecondFactorMethod"
|
||||
text=linkText}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
@ -1,7 +1,8 @@
|
||||
{{text-field value=value
|
||||
type="tel"
|
||||
pattern='[0-9]{6}'
|
||||
maxlength='6'
|
||||
type=type
|
||||
pattern=pattern
|
||||
maxlength=maxlength
|
||||
class="second-factor-token-input"
|
||||
id=inputId
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
|
@ -46,12 +46,12 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{#second-factor-form}}
|
||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor'}}
|
||||
{{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}}
|
||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}}
|
||||
{{/second-factor-form}}
|
||||
</form>
|
||||
{{/if}}
|
||||
|
||||
|
||||
{{/d-modal-body}}
|
||||
|
||||
<div class="modal-footer">
|
||||
|
@ -28,12 +28,12 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{#second-factor-form}}
|
||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor'}}
|
||||
{{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}}
|
||||
{{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}}
|
||||
{{/second-factor-form}}
|
||||
</form>
|
||||
{{/if}}
|
||||
|
||||
|
||||
{{#if showLoginButtons}}
|
||||
{{login-buttons
|
||||
canLoginLocalWithEmail=canLoginLocalWithEmail
|
||||
|
@ -17,11 +17,9 @@
|
||||
{{else}}
|
||||
<form>
|
||||
{{#if secondFactorRequired}}
|
||||
<h2>{{i18n 'login.second_factor_title'}}</h2>
|
||||
<p>{{i18n 'login.second_factor_description'}}</p>
|
||||
<div class="input">
|
||||
{{input value=secondFactor id="second-factor" autofocus="autofocus"}}
|
||||
</div>
|
||||
{{#second-factor-form secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
|
||||
{{text-field value=secondFactor id="second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus" secondFactorMethod=secondFactorMethod}}
|
||||
{{/second-factor-form}}
|
||||
{{d-button action="submit" class='btn-primary' label='submit'}}
|
||||
{{else}}
|
||||
<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}}
|
||||
{{/if}}
|
||||
</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>
|
||||
{{/if}}
|
||||
|
||||
|
@ -555,6 +555,68 @@
|
||||
.tag-notifications .tag-controls {
|
||||
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 {
|
||||
|
@ -54,6 +54,9 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
#second-factor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// styles used on the
|
||||
|
@ -130,6 +130,9 @@
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
#second-factor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// styles for the
|
||||
|
@ -386,10 +386,10 @@ class Admin::UsersController < Admin::AdminController
|
||||
|
||||
def disable_second_factor
|
||||
guardian.ensure_can_disable_second_factor!(@user)
|
||||
user_second_factor = @user.user_second_factor
|
||||
raise Discourse::InvalidParameters unless user_second_factor
|
||||
user_second_factor = @user.user_second_factors
|
||||
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)
|
||||
|
||||
Jobs.enqueue(
|
||||
|
@ -246,10 +246,11 @@ class SessionController < ApplicationController
|
||||
if payload = login_error_check(user)
|
||||
render json: payload
|
||||
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(
|
||||
error: I18n.t("login.invalid_second_factor_code"),
|
||||
reason: "invalid_second_factor"
|
||||
reason: "invalid_second_factor",
|
||||
backup_enabled: user.backup_codes_enabled?
|
||||
)
|
||||
end
|
||||
|
||||
@ -260,17 +261,18 @@ class SessionController < ApplicationController
|
||||
def email_login
|
||||
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
|
||||
second_factor_token = params[:second_factor_token]
|
||||
second_factor_method = params[:second_factor_method].to_i
|
||||
token = params[:token]
|
||||
valid_token = !!EmailToken.valid_token_format?(token)
|
||||
user = EmailToken.confirmable(token)&.user
|
||||
|
||||
if valid_token && user&.totp_enabled?
|
||||
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
||||
|
||||
if !second_factor_token.present?
|
||||
@second_factor_required = true
|
||||
@backup_codes_enabled = true if user&.backup_codes_enabled?
|
||||
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')
|
||||
return render layout: 'no_ember'
|
||||
end
|
||||
|
@ -12,7 +12,7 @@ class UsersController < ApplicationController
|
||||
requires_login only: [
|
||||
:username, :update, :user_preferences_redirect, :upload_user_image,
|
||||
: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: [
|
||||
@ -454,7 +454,7 @@ class UsersController < ApplicationController
|
||||
|
||||
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"
|
||||
end
|
||||
|
||||
@ -467,7 +467,7 @@ class UsersController < ApplicationController
|
||||
|
||||
if !valid_second_factor
|
||||
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')
|
||||
elsif @invalid_password
|
||||
@user.errors.add(:password, :invalid)
|
||||
@ -494,7 +494,8 @@ class UsersController < ApplicationController
|
||||
MultiJson.dump(
|
||||
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
||||
admin: @user.admin?,
|
||||
second_factor_required: !valid_second_factor
|
||||
second_factor_required: !valid_second_factor,
|
||||
backup_enabled: @user.backup_codes_enabled?
|
||||
)
|
||||
)
|
||||
end
|
||||
@ -524,7 +525,8 @@ class UsersController < ApplicationController
|
||||
render json: {
|
||||
is_developer: UsernameCheckerService.is_developer?(@user.email),
|
||||
admin: @user.admin?,
|
||||
second_factor_required: !valid_second_factor
|
||||
second_factor_required: !valid_second_factor,
|
||||
backup_enabled: @user.backup_codes_enabled?
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -575,16 +577,19 @@ class UsersController < ApplicationController
|
||||
|
||||
email_token_user = EmailToken.confirmable(token)&.user
|
||||
totp_enabled = email_token_user&.totp_enabled?
|
||||
backup_enabled = email_token_user&.backup_codes_enabled?
|
||||
second_factor_token = params[:second_factor_token]
|
||||
second_factor_method = params[:second_factor_method].to_i
|
||||
confirm_email = false
|
||||
|
||||
confirm_email =
|
||||
if totp_enabled
|
||||
@second_factor_required = true
|
||||
@backup_codes_enabled = true
|
||||
@message = I18n.t("login.second_factor_title")
|
||||
|
||||
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
|
||||
else
|
||||
@error = I18n.t("login.invalid_second_factor_code")
|
||||
@ -956,19 +961,43 @@ class UsersController < ApplicationController
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
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|
|
||||
RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed!
|
||||
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
|
||||
|
||||
unless current_user.authenticate_totp(params[:second_factor_token])
|
||||
@ -980,13 +1009,18 @@ class UsersController < ApplicationController
|
||||
if params[:enable] == "true"
|
||||
user_second_factor.update!(enabled: true)
|
||||
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(
|
||||
:critical_user_email,
|
||||
type: :account_second_factor_disabled,
|
||||
user_id: current_user.id
|
||||
)
|
||||
Jobs.enqueue(
|
||||
:critical_user_email,
|
||||
type: :account_second_factor_disabled,
|
||||
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
|
||||
|
||||
render json: success_json
|
||||
|
@ -43,9 +43,10 @@ class UsersEmailController < ApplicationController
|
||||
end
|
||||
|
||||
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
|
||||
@backup_codes_enabled = true if user.backup_codes_enabled?
|
||||
|
||||
if params[:second_factor_token].present?
|
||||
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
||||
|
@ -3,12 +3,13 @@ module SecondFactorManager
|
||||
|
||||
def 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
|
||||
|
||||
def create_totp(opts = {})
|
||||
if !self.user_second_factor
|
||||
self.create_user_second_factor!({
|
||||
if !self.user_second_factors.totp
|
||||
UserSecondFactor.create!({
|
||||
user_id: self.id,
|
||||
method: UserSecondFactor.methods[:totp],
|
||||
data: ROTP::Base32.random_base32
|
||||
}.merge(opts))
|
||||
@ -23,18 +24,88 @@ module SecondFactorManager
|
||||
totp = self.totp
|
||||
last_used = 0
|
||||
|
||||
if self.user_second_factor.last_used
|
||||
last_used = self.user_second_factor.last_used.to_i
|
||||
if self.user_second_factors.totp.last_used
|
||||
last_used = self.user_second_factors.totp.last_used.to_i
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
def totp_enabled?
|
||||
!!(self&.user_second_factor&.enabled?) &&
|
||||
!!(self&.user_second_factors&.totp&.enabled?) &&
|
||||
!SiteSetting.enable_sso &&
|
||||
SiteSetting.enable_local_logins
|
||||
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
|
||||
|
@ -67,7 +67,7 @@ class User < ActiveRecord::Base
|
||||
has_one :google_user_info, dependent: :destroy
|
||||
has_one :oauth2_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_profile, dependent: :destroy, inverse_of: :user
|
||||
has_one :single_sign_on_record, dependent: :destroy
|
||||
|
@ -1,11 +1,18 @@
|
||||
class UserSecondFactor < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
scope :backup_codes, -> { where(method: 2, enabled: true) }
|
||||
|
||||
def self.methods
|
||||
@methods ||= Enum.new(
|
||||
totp: 1,
|
||||
backup_codes: 2,
|
||||
)
|
||||
end
|
||||
|
||||
def self.totp
|
||||
where(method: 1).first
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
|
@ -73,7 +73,8 @@ class UserSerializer < BasicUserSerializer
|
||||
:primary_group_flair_bg_color,
|
||||
:primary_group_flair_color,
|
||||
:staged,
|
||||
:second_factor_enabled
|
||||
:second_factor_enabled,
|
||||
:second_factor_backup_enabled
|
||||
|
||||
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
|
||||
has_many :groups, embed: :object, serializer: BasicGroupSerializer
|
||||
@ -151,6 +152,14 @@ class UserSerializer < BasicUserSerializer
|
||||
object.totp_enabled?
|
||||
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
|
||||
!(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio)
|
||||
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') %>
|
||||
<%= hidden_field_tag 'second_factor_method', '1' %>
|
||||
|
@ -5,8 +5,8 @@
|
||||
<%end%>
|
||||
|
||||
<%if @second_factor_required%>
|
||||
<div style="display: flex;">
|
||||
<div style="margin: auto;">
|
||||
<div id="simple-container">
|
||||
<div id="primary-second-factor-form">
|
||||
<%= form_tag(method: "post") do%>
|
||||
<h2><%=t "login.second_factor_title" %></h2>
|
||||
<%= label_tag(:second_factor_token, t("login.second_factor_description")) %>
|
||||
@ -14,9 +14,24 @@
|
||||
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %>
|
||||
<%end%>
|
||||
</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>
|
||||
<%end%>
|
||||
|
||||
|
||||
|
||||
<% content_for :title do %><%=t "email_login.title" %><% end %>
|
||||
|
||||
<%- content_for(:no_ember_head) do %>
|
||||
|
@ -8,11 +8,25 @@
|
||||
<% if @error %><p><%= @error %></p><% end %>
|
||||
|
||||
<% if @second_factor_required %>
|
||||
<%=form_tag({}, method: :put) do %>
|
||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||
<%= render 'common/second_factor_text_field' %><br><br>
|
||||
<%= submit_tag t('submit')%>
|
||||
<% end %>
|
||||
<div id="primary-second-factor-form">
|
||||
<%=form_tag({}, method: :put) do %>
|
||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||
<%= render 'common/second_factor_text_field' %><br><br>
|
||||
<%= 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 %>
|
||||
<% else %>
|
||||
<%=form_tag({}, method: :put) do %>
|
||||
|
@ -8,16 +8,33 @@
|
||||
<br>
|
||||
<a class="btn" href="/"><%= t('change_email.please_continue', site_name: SiteSetting.title) %></a>
|
||||
<% elsif @update_result == :invalid_second_factor%>
|
||||
<h2><%= t('login.second_factor_title') %></h2>
|
||||
<br>
|
||||
<%=form_tag({}, method: :put) do %>
|
||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||
<%= text_field_tag(:second_factor_token, nil, autofocus: true) %><br>
|
||||
<% if @show_invalid_second_factor_error %>
|
||||
<div class='alert alert-error'><%= t('login.invalid_second_factor_code') %></div>
|
||||
<div id="primary-second-factor-form">
|
||||
<h2><%= t('login.second_factor_title') %></h2>
|
||||
<br>
|
||||
<%=form_tag({}, method: :put) do %>
|
||||
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
|
||||
<div><%= render 'common/second_factor_text_field' %></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 %>
|
||||
<%= submit_tag t('submit'), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%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 %>
|
||||
<div class='alert alert-error'>
|
||||
<%=t 'change_email.already_done' %>
|
||||
|
@ -738,6 +738,19 @@ en:
|
||||
choose_new: "Choose a new 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:
|
||||
title: "Two Factor Authentication"
|
||||
disable: "Disable two factor authentication"
|
||||
@ -1144,6 +1157,10 @@ en:
|
||||
password: "Password"
|
||||
second_factor_title: "Two Factor Authentication"
|
||||
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"
|
||||
caps_lock_warning: "Caps Lock is on"
|
||||
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."
|
||||
second_factor_title: "Two Factor Authentication"
|
||||
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:
|
||||
no_accounts_associated: "No accounts associated"
|
||||
|
@ -345,6 +345,8 @@ Discourse::Application.routes.draw do
|
||||
post "#{root_path}/second_factors" => "users#create_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"
|
||||
get "#{root_path}/hp" => "users#get_honeypot_value"
|
||||
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 }
|
||||
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-backup" => "users#preferences", 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 }
|
||||
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
|
||||
klass.order(order.reject(&:blank?).join(","))
|
||||
else
|
||||
klass.includes(:user_stat, :user_second_factor)
|
||||
klass.includes(:user_stat, :user_second_factors)
|
||||
.order(order.reject(&:blank?).join(","))
|
||||
end
|
||||
end
|
||||
|
@ -1,10 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SecondFactorManager do
|
||||
let(:user_second_factor) { Fabricate(:user_second_factor) }
|
||||
let(:user) { user_second_factor.user }
|
||||
let(:user_second_factor_totp) { Fabricate(:user_second_factor_totp) }
|
||||
let(:user) { user_second_factor_totp.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
|
||||
it 'should return the right data' do
|
||||
totp = nil
|
||||
@ -14,7 +17,7 @@ RSpec.describe SecondFactorManager do
|
||||
end.to change { UserSecondFactor.count }.by(1)
|
||||
|
||||
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
|
||||
|
||||
@ -37,7 +40,7 @@ RSpec.describe SecondFactorManager do
|
||||
describe '#totp_provisioning_uri' do
|
||||
it 'should return the right uri' do
|
||||
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
|
||||
@ -45,12 +48,12 @@ RSpec.describe SecondFactorManager do
|
||||
describe '#authenticate_totp' do
|
||||
it 'should be able to authenticate a token' 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
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
@ -58,14 +61,14 @@ RSpec.describe SecondFactorManager do
|
||||
describe 'when token is blank' do
|
||||
it 'should be false' do
|
||||
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
|
||||
|
||||
describe 'when token is invalid' do
|
||||
it 'should be false' do
|
||||
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
|
||||
@ -79,7 +82,7 @@ RSpec.describe SecondFactorManager do
|
||||
|
||||
describe "when user's second factor record is disabled" 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)
|
||||
end
|
||||
end
|
||||
@ -107,4 +110,85 @@ RSpec.describe SecondFactorManager do
|
||||
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
|
||||
|
@ -1,6 +1,14 @@
|
||||
Fabricator(:user_second_factor) do
|
||||
Fabricator(:user_second_factor_totp, from: :user_second_factor) do
|
||||
user
|
||||
data 'rcyryaqage3jexfj'
|
||||
enabled true
|
||||
method UserSecondFactor.methods[:totp]
|
||||
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
|
||||
it 'should retain the right order' do
|
||||
expect(described_class.methods[:totp]).to eq(1)
|
||||
expect(described_class.methods[:backup_codes]).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -808,12 +808,14 @@ RSpec.describe Admin::UsersController do
|
||||
|
||||
describe '#disable_second_factor' do
|
||||
let(:second_factor) { user.create_totp }
|
||||
let(:second_factor_backup) { user.generate_backup_codes }
|
||||
|
||||
describe 'as an admin' do
|
||||
before do
|
||||
sign_in(admin)
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@ -838,7 +840,7 @@ RSpec.describe Admin::UsersController do
|
||||
|
||||
describe 'when user does not have second factor enabled' 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"
|
||||
|
||||
|
@ -146,7 +146,8 @@ RSpec.describe SessionController do
|
||||
end
|
||||
|
||||
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
|
||||
it 'should return a second factor prompt' do
|
||||
@ -167,24 +168,55 @@ RSpec.describe SessionController do
|
||||
end
|
||||
|
||||
describe 'errors on incorrect 2-factor' do
|
||||
it 'does not log in with incorrect two factor' do
|
||||
post "/session/email-login/#{email_token.token}", params: { second_factor_token: "0000" }
|
||||
context 'when using totp method' do
|
||||
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(
|
||||
"login.invalid_second_factor_code"
|
||||
))
|
||||
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
|
||||
"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
|
||||
|
||||
describe 'allows successful 2-factor' do
|
||||
it 'logs in correctly' do
|
||||
post "/session/email-login/#{email_token.token}", params: {
|
||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
||||
}
|
||||
context 'when using totp method' do
|
||||
it 'logs in correctly' do
|
||||
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
|
||||
@ -899,7 +931,8 @@ RSpec.describe SessionController do
|
||||
end
|
||||
|
||||
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
|
||||
it 'should return the right response' do
|
||||
@ -916,35 +949,74 @@ RSpec.describe SessionController do
|
||||
end
|
||||
|
||||
describe 'when second factor token is invalid' do
|
||||
it 'should return the right response' do
|
||||
post "/session.json", params: {
|
||||
login: user.username,
|
||||
password: 'myawesomepassword',
|
||||
second_factor_token: '00000000'
|
||||
}
|
||||
context 'when using totp 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[:totp]
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
|
||||
'login.invalid_second_factor_code'
|
||||
))
|
||||
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 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
|
||||
|
||||
describe 'when second factor token is valid' do
|
||||
it 'should log the user in' do
|
||||
post "/session.json", params: {
|
||||
login: user.username,
|
||||
password: 'myawesomepassword',
|
||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
user.reload
|
||||
context 'when using totp method' do
|
||||
it 'should log the user in' do
|
||||
post "/session.json", params: {
|
||||
login: user.username,
|
||||
password: 'myawesomepassword',
|
||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||
second_factor_method: UserSecondFactor.methods[:totp]
|
||||
}
|
||||
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(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)
|
||||
expect(UserAuthToken.hash_token(cookies[:_t]))
|
||||
.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
|
||||
|
@ -190,7 +190,7 @@ describe UsersController do
|
||||
)
|
||||
|
||||
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(UserAuthToken.where(id: user_auth_token.id).count).to eq(0)
|
||||
@ -248,16 +248,20 @@ describe UsersController do
|
||||
end
|
||||
|
||||
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
|
||||
token = user.email_tokens.create!(email: user.email).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"))
|
||||
|
||||
@ -273,7 +277,8 @@ describe UsersController do
|
||||
|
||||
put "/u/password-reset/#{token}", params: {
|
||||
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
|
||||
@ -400,7 +405,7 @@ describe UsersController do
|
||||
end
|
||||
|
||||
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) }
|
||||
|
||||
it 'does not log in when token required' do
|
||||
@ -415,7 +420,10 @@ describe UsersController do
|
||||
it 'should display the right error' do
|
||||
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.body).to include(I18n.t('login.second_factor_description'));
|
||||
@ -424,7 +432,10 @@ describe UsersController do
|
||||
end
|
||||
|
||||
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(session[:current_user_id]).to eq(admin.id)
|
||||
@ -2795,7 +2806,7 @@ describe UsersController do
|
||||
|
||||
it 'succeeds on correct password' do
|
||||
user.create_totp
|
||||
user.user_second_factor.update!(data: "abcdefghijklmnop")
|
||||
user.user_second_factors.totp.update!(data: "abcdefghijklmnop")
|
||||
|
||||
post "/users/second_factors.json", params: {
|
||||
password: 'myawesomepassword'
|
||||
@ -2816,12 +2827,13 @@ describe UsersController do
|
||||
end
|
||||
|
||||
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
|
||||
it 'should return the right response' do
|
||||
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)
|
||||
@ -2849,6 +2861,7 @@ describe UsersController do
|
||||
it 'returns the right response' do
|
||||
put "/users/second_factor.json", params: {
|
||||
second_factor_token: '000000',
|
||||
second_factor_method: UserSecondFactor.methods[:totp],
|
||||
enable: 'true',
|
||||
}
|
||||
|
||||
@ -2865,22 +2878,136 @@ describe UsersController do
|
||||
put "/users/second_factor.json", params: {
|
||||
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
||||
enable: 'true',
|
||||
second_factor_method: UserSecondFactor.methods[:totp]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
it 'should allow second factor 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[:totp]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
@ -71,7 +71,7 @@ describe UsersEmailController do
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
@ -95,7 +96,8 @@ describe UsersEmailController do
|
||||
|
||||
it 'confirms with a correct second token' do
|
||||
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)
|
||||
|
@ -988,7 +988,7 @@ describe UserMerger do
|
||||
it "deletes auth tokens" do
|
||||
Fabricate(: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
|
||||
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({
|
||||
success: false,
|
||||
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");
|
||||
});
|
||||
|
||||
fillIn("#second-factor", "0000");
|
||||
fillIn("input#second-factor", "0000");
|
||||
click(".password-reset form button");
|
||||
|
||||
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");
|
||||
|
||||
andThen(() => {
|
||||
|
@ -18,6 +18,11 @@ acceptance("User Preferences", {
|
||||
server.put("/u/second_factor.json", () => { //eslint-disable-line
|
||||
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", {
|
||||
loggedIn: true,
|
||||
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.second_factor_token) {
|
||||
if (data.second_factor_token && data.second_factor_token === "123456") {
|
||||
return response({ username: "eviltrout" });
|
||||
}
|
||||
|
||||
return response({
|
||||
error: "Invalid Second Factor",
|
||||
reason: "invalid_second_factor",
|
||||
backup_enabled: true,
|
||||
sent_to_email: "eviltrout@example.com",
|
||||
current_email: "current@example.com"
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user