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

View File

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

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.get("content")
.toggleSecondFactor(this.get("secondFactorToken"), enable)
.toggleSecondFactor(this.get("secondFactorToken"), enable, 1)
.then(response => {
if (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 { 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"
});
},

View File

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

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

View File

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

View File

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

View File

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

View File

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

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}}
{{/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}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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') %>
<%= hidden_field_tag 'second_factor_method', '1' %>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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