FEATURE: Second factor backup

This commit is contained in:
Maja Komel 2018-06-28 10:12:32 +02:00 committed by Joffrey JAFFEUX
parent c73f98c289
commit ec3e6a81a4
51 changed files with 1148 additions and 153 deletions

View File

@ -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);
}
});

View File

@ -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);
}
}
}
});

View File

@ -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";
}
});

View File

@ -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();

View File

@ -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,

View File

@ -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
);
}
});

View File

@ -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);

View File

@ -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"
}); });
}, },

View File

@ -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" });
}); });

View File

@ -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 });
}
});

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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 {

View File

@ -54,6 +54,9 @@
flex-direction: column; flex-direction: column;
} }
} }
#second-factor {
display: none;
}
} }
// styles used on the // styles used on the

View File

@ -130,6 +130,9 @@
padding: 4px 0; padding: 4px 0;
} }
} }
#second-factor {
display: none;
}
} }
// styles for the // styles for the

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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' %>

View 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 %>

View File

@ -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' %>

View File

@ -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 %>

View File

@ -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 %>

View File

@ -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' %>

View File

@ -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"

View File

@ -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"

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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(() => {

View File

@ -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: {

View File

@ -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"
);
});
});

View File

@ -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"
}); });