Merge pull request #5612 from discourse/featheredtoast-two-factor-login

Featheredtoast two factor login
This commit is contained in:
Guo Xiang Tan 2018-02-21 15:00:10 +08:00 committed by GitHub
commit 8964e75ad6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1392 additions and 81 deletions

View File

@ -175,6 +175,9 @@ gem 'logster'
gem 'sassc', require: false
gem 'rotp'
gem 'rqrcode'
if ENV["IMPORT"] == "1"
gem 'mysql2'
gem 'redcarpet'

View File

@ -73,6 +73,7 @@ GEM
uniform_notifier (~> 1.10.0)
byebug (9.0.6)
certified (1.0.0)
chunky_png (1.3.8)
coderay (1.1.2)
concurrent-ruby (1.0.5)
connection_pool (2.2.1)
@ -298,6 +299,9 @@ GEM
redis (~> 3.0, >= 3.0.4)
request_store (1.3.2)
rinku (2.0.2)
rotp (3.3.0)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec (3.6.0)
rspec-core (~> 3.6.0)
rspec-expectations (~> 3.6.0)
@ -479,6 +483,8 @@ DEPENDENCIES
redis
redis-namespace
rinku
rotp
rqrcode
rspec
rspec-html-matchers
rspec-rails

View File

@ -19,6 +19,11 @@ export default Ember.Controller.extend(CanCheckEmails, {
primaryGroupDirty: propertyNotEqual('originalPrimaryGroupId', 'model.primary_group_id'),
canDisableSecondFactor: Ember.computed.and(
'model.second_factor_enabled',
'model.can_disable_second_factor'
),
automaticGroups: function() {
return this.get("model.automaticGroups").map((g) => g.name).join(", ");
}.property("model.automaticGroups"),
@ -63,6 +68,7 @@ export default Ember.Controller.extend(CanCheckEmails, {
deleteAllPosts() { return this.get("model").deleteAllPosts(); },
anonymize() { return this.get('model').anonymize(); },
destroy() { return this.get('model').destroy(); },
disableSecondFactor() { return this.get('model').disableSecondFactor(); },
viewActionLogs() {
this.get('adminTools').showActionLogs(this, {

View File

@ -168,6 +168,14 @@ const AdminUser = Discourse.User.extend({
}).catch(popupAjaxError);
},
disableSecondFactor() {
return ajax(`/admin/users/${this.get('id')}/disable_second_factor`, {
type: 'PUT'
}).then(() => {
this.set('second_factor_enabled', false);
}).catch(popupAjaxError);
},
refreshBrowsers() {
return ajax("/admin/users/" + this.get('id') + "/refresh_browsers", {
type: 'POST'

View File

@ -156,6 +156,22 @@
</div>
</div>
{{/if}}
<div class='display-row'>
<div class='field'>{{i18n 'user.second_factor.title'}}</div>
<div class='value'>
{{#if model.second_factor_enabled}}
{{i18n "yes_value"}}
{{else}}
{{i18n "no_value"}}
{{/if}}
</div>
<div class='controls'>
{{#if canDisableSecondFactor}}
{{d-button action="disableSecondFactor" icon="unlock-alt" label="user.second_factor.disable"}}
{{/if}}
</div>
</div>
</section>
{{#if userFields}}

View File

@ -11,7 +11,7 @@ export default Ember.Component.extend({
}
Ember.run.schedule('afterRender', () => {
$('#login-account-password, #login-account-name').keydown(e => {
$('#login-account-password, #login-account-name, #login-second-factor').keydown(e => {
if (e.keyCode === 13) {
this.sendAction();
}

View File

@ -4,6 +4,7 @@ import showModal from 'discourse/lib/show-modal';
import { setting } from 'discourse/lib/computed';
import { findAll } from 'discourse/models/login-method';
import { escape } from 'pretty-text/sanitizer';
import computed from 'ember-addons/ember-computed-decorators';
// This is happening outside of the app via popup
const AuthErrors = [
@ -31,6 +32,9 @@ export default Ember.Controller.extend(ModalFunctionality, {
this.set('authenticate', null);
this.set('loggingIn', false);
this.set('loggedIn', false);
this.set('secondFactorRequired', false);
$("#credentials").show();
$("#second-factor").hide();
},
// Determines whether at least one login button is enabled
@ -38,9 +42,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
return findAll(this.siteSettings).length > 0;
}.property(),
loginButtonText: function() {
return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title');
}.property('loggingIn'),
@computed('loggingIn')
loginButtonLabel(loggingIn) {
return loggingIn ? 'login.logging_in' : 'login.title';
},
loginDisabled: Em.computed.or('loggingIn', 'loggedIn'),
@ -67,13 +72,24 @@ export default Ember.Controller.extend(ModalFunctionality, {
this.set('loggingIn', true);
ajax("/session", {
data: { login: this.get('loginName'), password: this.get('loginPassword') },
type: 'POST'
type: 'POST',
data: {
login: this.get('loginName'),
password: this.get('loginPassword'),
second_factor_token: this.get('loginSecondFactor')
},
}).then(function (result) {
// Successful login
if (result && result.error) {
self.set('loggingIn', false);
if (result.reason === 'not_activated') {
if (result.reason === 'invalid_second_factor' && !self.get('secondFactorRequired')) {
$('#modal-alert').hide();
self.set('secondFactorRequired', true);
$("#credentials").hide();
$("#second-factor").show();
return;
} else if (result.reason === 'not_activated') {
self.send('showNotActivated', {
username: self.get('loginName'),
sentTo: escape(result.sent_to_email),

View File

@ -8,6 +8,7 @@ import { userPath } from 'discourse/lib/url';
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'),
passwordRequired: true,
errorMessage: null,
successMessage: null,
@ -32,7 +33,8 @@ export default Ember.Controller.extend(PasswordValidation, {
url: userPath(`password-reset/${this.get('model.token')}.json`),
type: 'PUT',
data: {
password: this.get('accountPassword')
password: this.get('accountPassword'),
second_factor_token: this.get('secondFactor')
}
}).then(result => {
if (result.success) {
@ -45,10 +47,22 @@ export default Ember.Controller.extend(PasswordValidation, {
DiscourseURL.redirectTo(result.redirect_to || '/');
}
} else {
if (result.errors && result.errors.password && result.errors.password.length > 0) {
if (result.errors && result.errors.user_second_factor) {
this.setProperties({
secondFactorRequired: true,
password: null,
errorMessage: result.message
});
} else if (this.get('secondFactorRequired')) {
this.setProperties({
secondFactorRequired: false,
errorMessage: null
});
} else if (result.errors && result.errors.password && result.errors.password.length > 0) {
this.get('rejectedPasswords').pushObject(this.get('accountPassword'));
this.get('rejectedPasswordsMessages').set(this.get('accountPassword'), result.errors.password[0]);
}
if (result.message) {
this.set('errorMessage', result.message);
}

View File

@ -0,0 +1,73 @@
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,
password: null,
secondFactorImage: null,
secondFactorKey: null,
showSecondFactorKey: false,
errorMessage: null,
newUsername: null,
loaded: Ember.computed.and('secondFactorImage', 'secondFactorKey'),
@computed('loading')
submitButtonText(loading) {
return loading ? 'loading' : 'submit';
},
toggleSecondFactor(enable) {
if (!this.get('second_factor_token')) return;
this.set('loading', true);
this.get('content').toggleSecondFactor(this.get('second_factor_token'), enable)
.then(response => {
if (response.error) {
this.set('errorMessage', response.error);
return;
}
this.set('errorMessage',null);
DiscourseURL.redirectTo(userPath(`${this.get('content').username.toLowerCase()}/preferences`));
})
.catch(popupAjaxError)
.finally(() => this.set('loading', false));
},
actions: {
confirmPassword() {
if (!this.get('password')) return;
this.set('loading', true);
this.get('content').loadSecondFactorCodes(this.get('password'))
.then(response => {
if(response.error) {
this.set('errorMessage', response.error);
return;
}
this.setProperties({
errorMessage: null,
secondFactorKey: response.key,
secondFactorImage: response.qr,
});
})
.catch(popupAjaxError)
.finally(() => this.set('loading', false));
},
showSecondFactorKey() {
this.set('showSecondFactorKey', true);
},
enableSecondFactor() {
this.toggleSecondFactor(true);
},
disableSecondFactor() {
this.toggleSecondFactor(false);
}
}
});

View File

@ -304,6 +304,20 @@ const User = RestModel.extend({
});
},
loadSecondFactorCodes(password) {
return ajax("/u/second_factors.json", {
data: { password },
type: 'POST'
});
},
toggleSecondFactor(token, enable) {
return ajax("/u/second_factor.json", {
data: { second_factor_token: token, enable },
type: 'PUT'
});
},
loadUserAction(id) {
const stream = this.get('stream');
return ajax(`/user_actions/${id}.json`, { cache: 'false' }).then(result => {

View File

@ -110,6 +110,7 @@ export default function() {
this.route('username');
this.route('email');
this.route('second-factor');
this.route('about', { path: '/about-me' });
this.route('badgeTitle', { path: '/badge_title' });
this.route('card-badge', { path: '/card-badge' });

View File

@ -0,0 +1,15 @@
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') });
}
});

View File

@ -15,6 +15,10 @@ export default RestrictedUserRoute.extend({
},
actions: {
showTwoFactorModal() {
showModal('second-factor-intro');
},
showAvatarSelector() {
showModal('avatar-selector');

View File

@ -0,0 +1,16 @@
<div id="second-factor" style="display: none;">
<h3>{{i18n 'login.second_factor_title'}}</h3>
<p>{{i18n 'login.second_factor_description'}}</p>
<table>
<tr>
<td>
<label for='login-second-factor'>{{i18n 'login.second_factor_label'}}&nbsp;</label>
</td>
<td>
{{yield}}
</td>
<td></td>
</tr>
</table>
</div>

View File

@ -1,9 +1,9 @@
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword action="login"}}
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}}
{{#d-modal-body title="login.title" class="login-modal"}}
{{login-buttons action="externalLogin"}}
{{#if canLoginLocal}}
<form id='login-form' method='post'>
<div>
<div id="credentials">
<table>
<tr>
<td>
@ -15,10 +15,10 @@
</tr>
<tr>
<td>
<label for='login-account-password'>{{i18n 'login.password'}}&nbsp;</label>
<label for='login-account-password'>{{i18n 'login.password'}}&nbsp;</label>
</td>
<td>
{{text-field value=loginPassword type="password" id="login-account-password" maxlength="200"}} &nbsp;
{{text-field value=loginPassword type="password" id="login-account-password" maxlength="200"}} &nbsp;
</td>
</tr>
<tr>
@ -29,8 +29,13 @@
</tr>
</table>
</div>
{{#second-factor-form}}
{{text-field value=loginSecondFactor
id="login-second-factor"
autocorrect="off"
autocapitalize="off"
autofocus="autofocus"}}
{{/second-factor-form}}
</form>
{{/if}}
{{authMessage}}
@ -43,11 +48,11 @@
{{/if}}
{{#if canLoginLocal}}
<button class='btn btn-large btn-primary'
disabled={{loginDisabled}}
{{action "login"}}>
{{d-icon "unlock"}}&nbsp;{{loginButtonText}}
</button>
{{d-button action="login"
icon="unlock"
label=loginButtonLabel
disabled=loginDisabled
class='btn btn-large btn-primary'}}
{{#if showSignupLink}}
<button class="btn btn-large" id="new-account-link" {{action "showCreateAccount"}}>

View File

@ -1,9 +1,9 @@
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword action="login"}}
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}}
{{#d-modal-body title="login.title" class="login-modal"}}
{{login-buttons action="externalLogin"}}
{{#if canLoginLocal}}
<form id='login-form' method='post'>
<div>
<div id="credentials">
<table>
<tr>
<td><label for='login-account-name'>{{i18n 'login.username'}}</label></td>
@ -22,6 +22,9 @@
</tr>
</table>
</div>
{{#second-factor-form}}
{{text-field value=loginSecondFactor id="login-second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus"}}
{{/second-factor-form}}
</form>
{{/if}}
{{authMessage}}
@ -30,9 +33,11 @@
<div class="modal-footer">
{{#if canLoginLocal}}
<button form="login-form" type="submit" class="btn btn-large btn-primary" disabled={{loginDisabled}} {{action "login"}}>
{{d-icon "unlock"}}&nbsp;{{loginButtonText}}
</button>
{{d-button action="login"
icon="unlock"
label=loginButtonLabel
disabled=loginDisabled
class='btn btn-large btn-primary'}}
{{#if showSignupLink}}
<button class="btn btn-large" id="new-account-link" {{action "createAccount"}}>

View File

@ -0,0 +1,6 @@
{{#d-modal-body title="user.second_factor.title"}}
<div>{{{i18n 'user.second_factor.extended_description'}}}</div>
{{/d-modal-body}}
<div class="modal-footer">
</div>

View File

@ -16,20 +16,28 @@
{{/if}}
{{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>
{{d-button action="submit" class='btn-primary' label='submit'}}
{{else}}
<h2>{{i18n 'user.change_password.choose'}}</h2>
<h2>{{i18n 'user.change_password.choose'}}</h2>
<div class="input">
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn autofocus="autofocus"}}
&nbsp;{{input-tip validation=passwordValidation}}
</div>
<div class="input">
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn autofocus="autofocus"}}
&nbsp;{{input-tip validation=passwordValidation}}
</div>
<div class="instructions">
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}">
{{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}</div>
</div>
<div class="instructions">
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}">
{{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}</div>
</div>
<button class='btn btn-primary' {{action "submit"}}>{{i18n 'user.change_password.set_password'}}</button>
{{d-button action="submit" class='btn-primary' label='user.change_password.set_password'}}
{{/if}}
{{#if errorMessage}}
<br/><br/>

View File

@ -0,0 +1,112 @@
<section class='user-content user-preferences'>
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
<h3>{{i18n 'user.second_factor.title'}}</h3>
</div>
</div>
{{#if errorMessage}}
<div class="control-group">
<div class="instructions">
<div class='alert alert-error'>{{errorMessage}}</div>
</div>
</div>
{{/if}}
{{#if model.second_factor_enabled}}
<label class='control-label'>{{i18n 'login.second_factor_label'}}</label>
<div class="control-group">
<div class="controls">
{{text-field value=second_factor_token
id="second_factor_token"
classNames="input-large"
autofocus="autofocus"}}
</div>
<div class='instructions'>
{{i18n 'user.second_factor.disable_description'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action="disableSecondFactor"
class="btn btn-primary"
disabled=loading
label=submitButtonText}}
</div>
</div>
{{else}}
{{#if loaded}}
<div class="control-group">
<div class="controls">
{{i18n 'user.second_factor.enable_description'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{{secondFactorImage}}}
<p>
{{#if showSecondFactorKey}}
{{secondFactorKey}}
{{else}}
<a {{action "showSecondFactorKey"}}>{{i18n 'user.second_factor.show_key_description'}}</a>
{{/if}}
</p>
</div>
</div>
<div class="control-group">
<label class="control-label input-prepend">{{i18n 'login.second_factor_label'}}</label>
<div class="controls">
{{text-field value=second_factor_token
id="second-factor-token"
classNames="input-xxlarge"
autofocus="autofocus"}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action="enableSecondFactor"
class="btn btn-primary"
disabled=loading
label=submitButtonText}}
</div>
</div>
{{else}}
<div class="control-group">
<label class='control-label'>{{i18n 'user.password.title'}}</label>
<div class="controls">
{{text-field value=password
id="password"
type="password"
classNames="input-xxlarge"
autofocus="autofocus"}}
</div>
<div class='instructions'>
{{i18n 'user.second_factor.confirm_password_description'}}
</div>
</div>
<div class="control-group">
<div class="controls">
{{d-button action="confirmPassword"
class="btn btn-primary"
disabled=loading
label=submitButtonText}}
{{#if saved}}{{i18n 'saved'}}{{/if}}
</div>
</div>
{{/if}}
{{/if}}
</form>
</section>

View File

@ -66,6 +66,25 @@
{{passwordProgress}}
</div>
</div>
<div class="control-group pref-second-factor">
<label class="control-label">{{i18n 'user.second_factor.title'}}</label>
<div class="controls">
{{#link-to "preferences.second-factor" class="btn"}}
{{#if model.second_factor_enabled}}
{{d-icon "unlock-alt"}}
{{i18n 'user.second_factor.disable'}}
{{else}}
{{d-icon "lock"}}
{{i18n 'user.second_factor.enable'}}
{{/if}}
{{/link-to}}
</div>
<div class="instructions">
<a href {{action "showTwoFactorModal"}}>{{i18n 'user.second_factor.info_prompt'}}</a>
</div>
</div>
{{/if}}
<div class="control-group pref-avatar">

View File

@ -25,7 +25,8 @@ class Admin::UsersController < Admin::AdminController
:generate_api_key,
:revoke_api_key,
:anonymize,
:reset_bounce_score]
:reset_bounce_score,
:disable_second_factor]
def index
users = ::AdminUserIndexQuery.new(params).find_users
@ -340,6 +341,23 @@ class Admin::UsersController < Admin::AdminController
}
end
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.destroy!
StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user)
Jobs.enqueue(
:critical_user_email,
type: :account_second_factor_disabled,
user_id: @user.id
)
render json: success_json
end
def destroy
user = User.find_by(id: params[:id].to_i)
guardian.ensure_can_delete_user!(user)

View File

@ -188,6 +188,10 @@ class SessionController < ApplicationController
end
def create
unless params[:second_factor_token].blank?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
params.require(:login)
params.require(:password)
@ -221,6 +225,13 @@ class SessionController < ApplicationController
if payload = login_error_check(user)
render json: payload
else
if user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token])
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code"),
reason: "invalid_second_factor"
)
end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end
end
@ -228,21 +239,32 @@ class SessionController < ApplicationController
def email_login
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
if EmailToken.valid_token_format?(params[:token]) && (user = EmailToken.confirm(params[:token]))
if params[:second_factor_token].present?
@error = I18n.t("login.invalid_second_factor_code")
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
token = params[:token]
valid_token = !!EmailToken.valid_token_format?(token)
user = EmailToken.confirmable(token)&.user
if valid_token && user&.totp_enabled? && !user.authenticate_totp(params[:second_factor_token])
@second_factor_required = true
@error = I18n.t('login.invalid_second_factor_code')
elsif user = EmailToken.confirm(token)
if login_not_approved_for?(user)
@error = login_not_approved[:error]
return render layout: 'no_ember'
elsif payload = login_error_check(user)
@error = payload[:error]
return render layout: 'no_ember'
else
log_on_user(user)
redirect_to path("/")
return redirect_to path("/")
end
else
@error = I18n.t('email_login.invalid_token')
return render layout: 'no_ember'
end
render layout: 'no_ember'
end
def forgot_password

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
:preferences, :create_second_factor, :update_second_factor
]
skip_before_action :check_xhr, only: [
@ -470,12 +470,24 @@ class UsersController < ApplicationController
end
end
totp_enabled = @user&.totp_enabled?
if !totp_enabled || @user.authenticate_totp(params[:second_factor_token])
secure_session["second-factor-#{token}"] = "true"
end
valid_second_factor = secure_session["second-factor-#{token}"] == "true"
if !@user
@error = I18n.t('password_reset.no_token')
elsif request.put?
@invalid_password = params[:password].blank? || params[:password].length > User.max_password_length
if @invalid_password
if !valid_second_factor
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
@user.errors.add(:user_second_factor, :invalid)
@error = I18n.t('login.invalid_second_factor_code')
elsif @invalid_password
@user.errors.add(:password, :invalid)
else
@user.password = params[:password]
@ -484,6 +496,7 @@ class UsersController < ApplicationController
if @user.save
Invite.invalidate_for_email(@user.email) # invite link can't be used to log in anymore
secure_session["password-#{token}"] = nil
secure_session["second-factor-#{token}"] = nil
logon_after_password_reset
end
end
@ -496,9 +509,14 @@ class UsersController < ApplicationController
else
store_preloaded(
"password_reset",
MultiJson.dump(is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?)
MultiJson.dump(
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor
)
)
end
return redirect_to(wizard_path) if request.put? && Wizard.user_requires_completion?(@user)
end
@ -521,7 +539,11 @@ class UsersController < ApplicationController
}
end
else
render json: { is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin? }
render json: {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor
}
end
end
end
@ -550,7 +572,7 @@ class UsersController < ApplicationController
def admin_login
return redirect_to(path("/")) if current_user
if request.put?
if request.put? && params[:email].present?
RateLimiter.new(nil, "admin-login-hr-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "admin-login-min-#{request.remote_ip}", 3, 1.minute).performed!
@ -561,15 +583,29 @@ class UsersController < ApplicationController
else
@message = I18n.t("admin_login.errors.unknown_email_address")
end
elsif params[:token].present?
if EmailToken.valid_token_format?(params[:token])
@user = EmailToken.confirm(params[:token])
elsif (token = params[:token]).present?
valid_token = EmailToken.valid_token_format?(token)
if @user&.admin?
log_on_user(@user)
return redirect_to path("/")
if valid_token
if params[:second_factor_token].present?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
email_token_user = EmailToken.confirmable(token)&.user
totp_enabled = email_token_user.totp_enabled?
if !totp_enabled || email_token_user.authenticate_totp(params[:second_factor_token])
@user = EmailToken.confirm(token)
if @user && @user.admin?
log_on_user(@user)
return redirect_to path("/")
else
@message = I18n.t("admin_login.errors.unknown_email_address")
end
else
@message = I18n.t("admin_login.errors.unknown_email_address")
@second_factor_required = true
@message = I18n.t("login.second_factor_title")
end
else
@message = I18n.t("admin_login.errors.invalid_token")
@ -899,6 +935,60 @@ class UsersController < ApplicationController
render layout: 'no_ember'
end
def create_second_factor
RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed!
RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed!
unless current_user.confirm_password?(params[:password])
return render json: failed_json.merge(
error: I18n.t("login.incorrect_password")
)
end
qrcode_svg = RQRCode::QRCode.new(current_user.totp_provisioning_uri).as_svg(
offset: 0,
color: '000',
shape_rendering: 'crispEdges',
module_size: 4
)
render json: success_json.merge(
key: current_user.user_second_factor.data,
qr: qrcode_svg
)
end
def update_second_factor
params.require(:second_factor_token)
[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
raise Discourse::InvalidParameters unless user_second_factor
unless current_user.authenticate_totp(params[:second_factor_token])
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code")
)
end
if params[:enable] == "true"
user_second_factor.update!(enabled: true)
else
user_second_factor.destroy!
Jobs.enqueue(
:critical_user_email,
type: :account_second_factor_disabled,
user_id: current_user.id
)
end
render json: success_json
end
private
def honeypot_value

View File

@ -33,12 +33,32 @@ class UsersEmailController < ApplicationController
def confirm
expires_now
updater = EmailUpdater.new
@update_result = updater.confirm(params[:token])
if @update_result == :complete
updater.user.user_stat.reset_bounce_score!
log_on_user(updater.user)
token = EmailToken.confirmable(params[:token])
user = token&.user
change_request =
if user
user.email_change_requests.where(new_email_token_id: token.id).first
end
if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] &&
user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token])
@update_result = :invalid_second_factor
if params[:second_factor_token].present?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
@show_invalid_second_factor_error = true
end
else
updater = EmailUpdater.new
@update_result = updater.confirm(params[:token])
if @update_result == :complete
updater.user.user_stat.reset_bounce_score!
log_on_user(updater.user)
end
end
render layout: 'no_ember'

View File

@ -120,6 +120,15 @@ class UserNotifications < ActionMailer::Base
)
end
def account_second_factor_disabled(user, opts = {})
build_email(
user.email,
template: 'user_notifications.account_second_factor_disabled',
locale: user_locale(user),
email: user.email
)
end
def short_date(dt)
if dt.year == Time.now.year
I18n.l(dt, format: :short_no_year)

View File

@ -0,0 +1,38 @@
module SecondFactorManager
extend ActiveSupport::Concern
def totp
self.create_totp
ROTP::TOTP.new(self.user_second_factor.data, issuer: SiteSetting.title)
end
def create_totp(opts = {})
if !self.user_second_factor
self.create_user_second_factor!({
method: UserSecondFactor.methods[:totp],
data: ROTP::Base32.random_base32
}.merge(opts))
end
end
def totp_provisioning_uri
self.totp.provisioning_uri(self.email)
end
def authenticate_totp(token)
totp = self.totp
last_used = 0
if self.user_second_factor.last_used
last_used = self.user_second_factor.last_used.to_i
end
authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 0, last_used)
self.user_second_factor.update!(last_used: DateTime.now) if authenticated
!!authenticated
end
def totp_enabled?
!!(self&.user_second_factor&.enabled?)
end
end

View File

@ -18,6 +18,7 @@ class User < ActiveRecord::Base
include Searchable
include Roleable
include HasCustomFields
include SecondFactorManager
# TODO: Remove this after 7th Jan 2018
self.ignored_columns = %w{email}
@ -60,6 +61,7 @@ class User < ActiveRecord::Base
has_one :github_user_info, dependent: :destroy
has_one :google_user_info, dependent: :destroy
has_one :oauth2_user_info, dependent: :destroy
has_one :user_second_factor, 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

@ -66,7 +66,8 @@ class UserHistory < ActiveRecord::Base
change_name: 48,
post_locked: 49,
post_unlocked: 50,
check_personal_message: 51)
check_personal_message: 51,
disabled_second_factor: 52)
end
# Staff actions is a subset of all actions, used to audit actions taken by staff users.
@ -110,7 +111,8 @@ class UserHistory < ActiveRecord::Base
:backup_destroy,
:post_locked,
:post_unlocked,
:check_personal_message]
:check_personal_message,
:disabled_second_factor]
end
def self.staff_action_ids

View File

@ -0,0 +1,23 @@
class UserSecondFactor < ActiveRecord::Base
belongs_to :user
def self.methods
@methods ||= Enum.new(
totp: 1,
)
end
end
# == Schema Information
#
# Table name: user_second_factors
#
# id :integer not null, primary key
# user_id :integer not null
# method :string
# data :string
# enabled :boolean default(FALSE), not null
# last_used :datetime
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -25,7 +25,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:user_fields,
:bounce_score,
:reset_bounce_score_after,
:can_view_action_logs
:can_view_action_logs,
:second_factor_enabled,
:can_disable_second_factor
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :api_key, serializer: ApiKeySerializer, embed: :objects
@ -34,6 +36,14 @@ class AdminDetailedUserSerializer < AdminUserSerializer
has_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects
has_many :groups, embed: :object, serializer: BasicGroupSerializer
def second_factor_enabled
object.totp_enabled?
end
def can_disable_second_factor
object&.id != scope.user.id
end
def can_revoke_admin
scope.can_revoke_admin?(object)
end

View File

@ -72,7 +72,8 @@ class UserSerializer < BasicUserSerializer
:primary_group_flair_url,
:primary_group_flair_bg_color,
:primary_group_flair_color,
:staged
:staged,
:second_factor_enabled
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :groups, embed: :object, serializer: BasicGroupSerializer
@ -145,6 +146,14 @@ class UserSerializer < BasicUserSerializer
(scope.is_staff? && object.staged?)
end
def include_second_factor_enabled?
(object&.id == scope.user&.id) || scope.is_staff?
end
def second_factor_enabled
object.totp_enabled?
end
def can_change_bio
!(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio)
end

View File

@ -305,6 +305,12 @@ class StaffActionLogger
target_user_id: user.id))
end
def log_disable_second_factor_auth(user, opts = {})
raise Discourse::InvalidParameters.new(:user) unless user
UserHistory.create(params(opts).merge(action: UserHistory.actions[:disabled_second_factor],
target_user_id: user.id))
end
def log_grant_admin(user, opts = {})
raise Discourse::InvalidParameters.new(:user) unless user
UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_admin],

View File

@ -4,6 +4,19 @@
</div>
<%end%>
<%if @second_factor_required%>
<div style="display: flex;">
<div style="margin: auto;">
<%= form_tag(method: "post") do%>
<h2><%=t "login.second_factor_title" %></h2>
<%= label_tag(:second_factor_token, t("login.second_factor_description")) %>
<div><%= text_field_tag(:second_factor_token) %></div>
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %>
<%end%>
</div>
</div>
<%end%>
<% content_for :title do %><%=t "email_login.title" %><% end %>
<%- content_for(:no_ember_head) do %>

View File

@ -5,6 +5,13 @@
<body>
<% if @message %>
<%= @message %>
<% if @second_factor_required %>
<%=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><br>
<%= submit_tag t('login.submit')%>
<% end %>
<% end %>
<% else %>
<%=form_tag({}, method: :put) do %>
<%= label_tag(:email, t('admin_login.email_input')) %>

View File

@ -7,6 +7,17 @@
<h2><%= t 'change_email.confirmed' %></h2>
<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>
<% end %>
<%= submit_tag t('submit'), class: "btn btn-primary" %>
<% end %>
<% else %>
<div class='alert alert-error'>
<%=t 'change_email.already_done' %>

View File

@ -129,13 +129,14 @@ module Discourse
# Configure sensitive parameters which will be filtered from the log file.
config.filter_parameters += [
:password,
:pop3_polling_password,
:api_key,
:s3_secret_access_key,
:twitter_consumer_secret,
:facebook_app_secret,
:github_client_secret
:password,
:pop3_polling_password,
:api_key,
:s3_secret_access_key,
:twitter_consumer_secret,
:facebook_app_secret,
:github_client_secret,
:second_factor_token,
]
# Enable the asset pipeline

View File

@ -207,6 +207,7 @@ en:
not_implemented: "That feature hasn't been implemented yet, sorry!"
no_value: "No"
yes_value: "Yes"
submit: "Submit"
generic_error: "Sorry, an error has occurred."
generic_error_with_reason: "An error occurred: %{error}"
sign_up: "Sign Up"
@ -707,6 +708,17 @@ en:
choose_new: "Choose a new password"
choose: "Choose a password"
second_factor:
title: "Two Factor Authentication"
enable: "Enable Two Factor Authentication"
disable: "Disable Two Factor Authentication"
confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication."
enable_description: "To complete Two Factor Authentication setup, scan the following QR code and submit a Two Factor Authentication code."
disable_description: "Enter a Two Factor Authentication code to disable."
show_key_description: "Or enter the key manually."
info_prompt: "What is Two Factor Authentication?"
extended_description: "Two Factor Authentication adds an extra security step to logging in by requiring a one-time token in addition to your password. These tokens are generated by compatible apps for iPhone or Android such as <a href=\"https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2\" target='_blank'>Google Authenticator</a>, <a href=\"https://play.google.com/store/apps/details?id=com.authy.authy\" target='_blank'>Authy</a>, and <a href=\"https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp\" target='_blank'>FreeOTP</a>."
change_about:
title: "Change About Me"
error: "There was an error changing this value."
@ -1097,6 +1109,9 @@ en:
title: "Log In"
username: "User"
password: "Password"
second_factor_title: "Two Factor Authentication Required"
second_factor_description: "Enter a generated verification code."
second_factor_label: "Code"
email_placeholder: "email or username"
caps_lock_warning: "Caps Lock is on"
error: "Unknown error"
@ -3262,6 +3277,7 @@ en:
post_locked: "post locked"
post_unlocked: "post unlocked"
check_personal_message: "check personal message"
disabled_second_factor: "disable 2 factor authentication"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -49,6 +49,7 @@ en:
loading: "Loading"
powered_by_html: 'Powered by <a href="https://www.discourse.org">Discourse</a>, best viewed with JavaScript enabled'
log_in: "Log In"
submit: "Submit"
purge_reason: "Automatically deleted as abandoned, deactivated account"
disable_remote_images_download_reason: "Remote images download was disabled because there wasn't enough disk space available."
@ -1761,6 +1762,7 @@ en:
login:
not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in."
incorrect_username_email_or_password: "Incorrect username, email or password"
incorrect_password: "Incorrect password"
wait_approval: "Thanks for signing up. We will notify you when your account has been approved."
active: "Your account is activated and ready to use."
activate_email: "<p>Youre almost done! We sent an activation mail to <b>%{email}</b>. Please follow the instructions in the mail to activate your account.</p><p>If it doesnt arrive, check your spam folder.</p>"
@ -1783,6 +1785,9 @@ en:
auth_complete: "Authentication is complete."
click_to_continue: "Click here to continue."
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 Required"
second_factor_description: "Enter a generated authentication code."
invalid_second_factor_code: "Invalid Two Factor Authentication Code"
user:
no_accounts_associated: "No accounts associated"
@ -2730,6 +2735,15 @@ en:
account_second_factor_disabled:
title: "Two Factor Authentication disabled"
subject_template: "[%{email_prefix}] Two Factor Authentication disabled"
text_body_template: |
Your accounts Two Factor Authentication at %{site_name} has been disabled. The account no longer needs a Two Factor Authentication code to sign in.
If you have any questions, [contact our friendly staff](%{base_url}/about).
digest:
why: "A brief summary of %{site_link} since your last visit on %{last_seen_at}"
since_last_visit: "Since your last visit"

View File

@ -129,6 +129,7 @@ Discourse::Application.routes.draw do
get "tl3_requirements"
put "anonymize"
post "reset_bounce_score"
put "disable_second_factor"
end
get "users/:id.json" => 'users#show', defaults: { format: 'json' }
get 'users/:id/:username' => 'users#show', constraints: { username: RouteFormat.username }
@ -302,6 +303,7 @@ Discourse::Application.routes.draw do
get "session/current" => "session#current"
get "session/csrf" => "session#csrf"
get "session/email-login/:token" => "session#email_login"
post "session/email-login/:token" => "session#email_login"
get "composer_messages" => "composer_messages#index"
post "composer/parse_html" => "composer#parse_html"
@ -329,12 +331,16 @@ Discourse::Application.routes.draw do
end
end
post "#{root_path}/second_factors" => "users#create_second_factor"
put "#{root_path}/second_factor" => "users#update_second_factor"
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"
get "#{root_path}/admin-login" => "users#admin_login"
put "#{root_path}/admin-login" => "users#admin_login"
get "#{root_path}/admin-login/:token" => "users#admin_login"
put "#{root_path}/admin-login/:token" => "users#admin_login"
post "#{root_path}/toggle-anon" => "users#toggle_anon"
post "#{root_path}/read-faq" => "users#read_faq"
get "#{root_path}/search/users" => "users#search_users"
@ -349,6 +355,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/activate-account/:token" => "users#activate_account"
put({ "#{root_path}/activate-account/:token" => "users#perform_account_activation" }.merge(index == 1 ? { as: 'perform_activate_account' } : {}))
get "#{root_path}/authorize-email/:token" => "users_email#confirm"
put "#{root_path}/authorize-email/:token" => "users_email#confirm"
get({
"#{root_path}/confirm-admin/:token" => "users#confirm_admin",
constraints: { token: /[0-9a-f]+/ }
@ -380,6 +387,7 @@ Discourse::Application.routes.draw do
put "#{root_path}/:username/preferences/badge_title" => "users#badge_title", 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 }
get "#{root_path}/:username/preferences/second-factor" => "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/preferences/card-badge" => "users#card_badge", constraints: { username: RouteFormat.username }

View File

@ -0,0 +1,12 @@
class CreateUserSecondFactors < ActiveRecord::Migration[5.1]
def change
create_table :user_second_factors do |t|
t.integer :user_id, null: false
t.integer :method, null: false
t.string :data, null: false
t.boolean :enabled, null: false, default: false
t.timestamp :last_used
t.timestamps
end
end
end

View File

@ -72,4 +72,8 @@ module UserGuardian
user == @user || is_staff?
end
def can_disable_second_factor?(user)
user && can_administer_user?(user)
end
end

View File

@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe SecondFactorManager do
let(:user_second_factor) { Fabricate(:user_second_factor) }
let(:user) { user_second_factor.user }
let(:another_user) { Fabricate(:user) }
describe '#totp' do
it 'should return the right data' do
totp = nil
expect do
totp = another_user.totp
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)
end
end
describe '#create_totp' do
it 'should create the right record' do
second_factor = another_user.create_totp(enabled: true)
expect(second_factor.method).to eq(UserSecondFactor.methods[:totp])
expect(second_factor.data).to be_present
expect(second_factor.enabled).to eq(true)
end
describe 'when user has a second factor' do
it 'should return nil' do
expect(user.create_totp).to eq(nil)
end
end
end
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}"
)
end
end
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)
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.authenticate_totp(token)).to eq(false)
end
end
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)
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)
end
end
end
describe '#totp_enabled?' do
describe 'when user does not have a second factor record' do
it 'should return false' do
expect(another_user.totp_enabled?).to eq(false)
end
end
describe "when user's second factor record is disabled" do
it 'should return false' do
user.user_second_factor.update!(enabled: false)
expect(user.totp_enabled?).to eq(false)
end
end
describe "when user's second factor record is enabled" do
it 'should return true' do
expect(user.totp_enabled?).to eq(true)
end
end
end
end

View File

@ -584,6 +584,55 @@ describe SessionController do
end
end
context 'when user has 2-factor logins' do
let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) }
describe 'when second factor token is missing' do
it 'should return the right response' do
post :create, params: {
login: user.username,
password: 'myawesomepassword',
}, format: :json
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
describe 'when second factor token is invalid' do
it 'should return the right response' do
post :create, params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: '00000000'
}, format: :json
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
describe 'when second factor token is valid' do
it 'should log the user in' do
post :create, params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now
}, format: :json
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
describe 'with a blocked IP' do
before do
screened_ip = Fabricate(:screened_ip_address)
@ -777,7 +826,32 @@ describe SessionController do
login: user.username, password: 'myawesomepassword'
}, format: :json
expect(response).not_to be_success
expect(response.status).to eq(429)
json = JSON.parse(response.body)
expect(json["error_type"]).to eq("rate_limit")
end
it 'rate limits second factor attempts' do
RateLimiter.enable
RateLimiter.clear_all!
3.times do
post :create, params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: '000000'
}, format: :json
expect(response).to be_success
end
post :create, params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: '000000'
}, format: :json
expect(response.status).to eq(429)
json = JSON.parse(response.body)
expect(json["error_type"]).to eq("rate_limit")
end

View File

@ -343,7 +343,7 @@ describe UsersController do
)
expect(response).to be_success
expect(response.body).to include('{"is_developer":false,"admin":false}')
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}')
user.reload
@ -406,6 +406,43 @@ describe UsersController do
expect(email_token.confirmed).to eq(false)
expect(UserAuthToken.where(id: user_token.id).count).to eq(1)
end
context '2 factor authentication required' do
let!(:second_factor) { Fabricate(:user_second_factor, user: user) }
it 'does not change with an invalid token' do
token = user.email_tokens.create!(email: user.email).token
get :password_reset, params: { token: token }
expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true}')
put :password_reset,
params: { token: token, password: 'hg9ow8yHG32O', second_factor_token: '000000' }
expect(response.body).to include(I18n.t("login.invalid_second_factor_code"))
user.reload
expect(user.confirm_password?('hg9ow8yHG32O')).not_to eq(true)
expect(user.user_auth_tokens.count).not_to eq(1)
end
it 'changes password with valid 2-factor tokens' do
token = user.email_tokens.create(email: user.email).token
get :password_reset, params: { token: token }
put :password_reset, params: {
token: token,
password: 'hg9ow8yHG32O',
second_factor_token: ROTP::TOTP.new(second_factor.data).now
}
user.reload
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true)
expect(user.user_auth_tokens.count).to eq(1)
end
end
end
context 'submit change' do
@ -475,7 +512,7 @@ describe UsersController do
end
end
describe '.admin_login' do
describe '#admin_login' do
let(:admin) { Fabricate(:admin) }
let(:user) { Fabricate(:user) }
@ -514,6 +551,32 @@ describe UsersController do
expect(session[:current_user_id]).to eq(admin.id)
end
end
describe 'when 2 factor authentication is enabled' do
let(:second_factor) { Fabricate(:user_second_factor, user: admin) }
render_views
it 'does not log in when token required' do
second_factor
token = admin.email_tokens.create(email: admin.email).token
get :admin_login, params: { token: token }
expect(response).not_to redirect_to('/')
expect(session[:current_user_id]).not_to eq(admin.id)
expect(response.body).to include(I18n.t('login.second_factor_description'));
end
it 'logs in when a valid 2-factor token is given' do
token = admin.email_tokens.create(email: admin.email).token
put :admin_login, params: {
token: token,
second_factor_token: ROTP::TOTP.new(second_factor.data).now
}
expect(response).to redirect_to('/')
expect(session[:current_user_id]).to eq(admin.id)
end
end
end
end

View File

@ -0,0 +1,6 @@
Fabricator(:user_second_factor) do
user
data 'rcyryaqage3jexfj'
enabled true
method UserSecondFactor.methods[:totp]
end

View File

@ -0,0 +1,9 @@
require 'rails_helper'
RSpec.describe UserSecondFactor do
describe '.methods' do
it 'should retain the right order' do
expect(described_class.methods[:totp]).to eq(1)
end
end
end

View File

@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe Admin::UsersController do
let(:admin) { Fabricate(:admin) }
let(:user) { Fabricate(:user) }
describe '#disable_second_factor' do
let(:second_factor) { user.create_totp }
describe 'as an admin' do
before do
sign_in(admin)
second_factor
expect(user.reload.user_second_factor).to eq(second_factor)
end
it 'should able to disable the second factor for another user' do
SiteSetting.queue_jobs = true
expect do
put "/admin/users/#{user.id}/disable_second_factor.json"
end.to change { Jobs::CriticalUserEmail.jobs.length }.by(1)
expect(response.status).to eq(200)
expect(user.reload.user_second_factor).to eq(nil)
job_args = Jobs::CriticalUserEmail.jobs.first["args"].first
expect(job_args["user_id"]).to eq(user.id)
expect(job_args["type"]).to eq('account_second_factor_disabled')
end
it 'should not be able to disable the second factor for the current user' do
put "/admin/users/#{admin.id}/disable_second_factor.json"
expect(response.status).to eq(403)
end
describe 'when user does not have second factor enabled' do
it 'should raise the right error' do
user.user_second_factor.destroy!
put "/admin/users/#{user.id}/disable_second_factor.json"
expect(response.status).to eq(400)
end
end
end
end
end

View File

@ -136,6 +136,44 @@ RSpec.describe SessionController do
date: I18n.l(user.suspended_till, format: :date_only)
))
end
context 'user has 2-factor logins' do
let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) }
describe 'requires second factor' do
it 'should return a second factor prompt' do
get "/session/email-login/#{email_token.token}"
expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
"login.second_factor_title"
))
end
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" }
expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
"login.invalid_second_factor_code"
))
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
}
expect(response).to redirect_to("/")
end
end
end
end
end
end

View File

@ -401,4 +401,118 @@ RSpec.describe UsersController do
end
end
end
describe '#create_second_factor' do
context 'when not logged in' do
it 'should return the right response' do
post "/users/second_factors.json", params: {
password: 'wrongpassword'
}
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
post "/users/second_factors.json", params: {
password: 'wrongpassword'
}
expect(response.status).to eq(200)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
"login.incorrect_password")
)
end
it 'succeeds on correct password' do
post "/users/second_factors.json", params: {
password: 'somecomplicatedpassword'
}
expect(response.status).to eq(200)
response_body = JSON.parse(response.body)
expect(response_body['key']).to eq(user.user_second_factor.data)
expect(response_body['qr']).to be_present
end
end
end
end
describe '#update_second_factor' do
let(:user_second_factor) { Fabricate(:user_second_factor, 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
}
expect(response.status).to eq(403)
end
end
context 'when logged in' do
before do
sign_in(user)
user_second_factor
end
context 'when user has totp setup' do
context 'when token is missing' do
it 'returns the right response' do
put "/users/second_factor.json", params: {
enable: 'true',
}
expect(response.status).to eq(400)
end
end
context 'when token is invalid' do
it 'returns the right response' do
put "/users/second_factor.json", params: {
second_factor_token: '000000',
enable: 'true',
}
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 for the user to be enabled' do
put "/users/second_factor.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
enable: 'true',
}
expect(response.status).to eq(200)
expect(user.reload.user_second_factor.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,
}
expect(response.status).to eq(200)
expect(user.reload.user_second_factor).to eq(nil)
end
end
end
end
end
end

View File

@ -2,7 +2,7 @@ require 'rails_helper'
describe UsersEmailController do
describe '.confirm' do
describe '#confirm' do
it 'errors out for invalid tokens' do
get "/u/authorize-email/asdfasdf"
@ -60,20 +60,56 @@ describe UsersEmailController do
expect(user.user_stat.bounce_score).to eq(0)
expect(user.user_stat.reset_bounce_score_after).to eq(nil)
end
context 'second factor required' do
let!(:second_factor) { Fabricate(:user_second_factor, user: user) }
it 'requires a second factor token' do
get "/u/authorize-email/#{user.email_tokens.last.token}"
expect(response.status).to eq(200)
response_body = response.body
expect(response_body).to include(I18n.t("login.second_factor_title"))
expect(response_body).not_to include(I18n.t("login.invalid_second_factor_code"))
end
it 'adds an error on a second factor attempt' do
get "/u/authorize-email/#{user.email_tokens.last.token}", params: {
second_factor_token: "000000"
}
expect(response.status).to eq(200)
expect(response.body).to include(I18n.t("login.invalid_second_factor_code"))
end
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
}
expect(response.status).to eq(200)
response_body = response.body
expect(response.body).not_to include(I18n.t("login.second_factor_title"))
expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code"))
end
end
end
end
describe '.update' do
describe '#update' do
let(:user) { Fabricate(:user) }
let(:new_email) { 'bubblegum@adventuretime.ooo' }
it "requires you to be logged in" do
put "/u/asdf/preferences/email.json"
put "/u/#{user.username}/preferences/email.json", params: { email: new_email }
expect(response.status).to eq(403)
end
context 'when logged in' do
let(:user) { Fabricate(:user) }
before do
sign_in(user)
end

View File

@ -24,6 +24,25 @@ acceptance("Password Reset", {
return response({success: "OK", message: I18n.t('password_reset.success')});
}
});
server.get('/u/confirm-email-token/requiretwofactor.json', () => { //eslint-disable-line
return response({ success: "OK" });
});
server.put('/u/password-reset/requiretwofactor.json', request => { //eslint-disable-line
const body = parsePostData(request.requestBody);
if (body.password === "perf3ctly5ecur3" && body.second_factor_token === "123123") {
return response({ success: "OK", message: I18n.t('password_reset.success') });
} else if (body.second_factor_token === "123123") {
return response({ success: false, errors: { password: ["invalid"] } });
} else {
return response({
success: false,
message: "invalid token",
errors: { user_second_factor: ["invalid token"] }
});
}
});
}
});
@ -58,4 +77,45 @@ QUnit.test("Password Reset Page", assert => {
andThen(() => {
assert.ok(!exists(".password-reset form"), "form is gone");
});
});
});
QUnit.test("Password Reset Page With Second Factor", assert => {
PreloadStore.store('password_reset', {
is_developer: false,
second_factor_required: true
});
visit("/u/password-reset/requiretwofactor");
andThen(() => {
assert.notOk(exists("#new-account-password"), "does not show the input");
assert.ok(exists("#second-factor"), "shows the second factor prompt");
});
fillIn('#second-factor', '0000');
click('.password-reset form button');
andThen(() => {
assert.ok(exists(".alert-error"), "shows 2 factor error");
assert.ok(
find(".alert-error").html().indexOf("invalid token") > -1,
"shows server validation error message"
);
});
fillIn('#second-factor', '123123');
click('.password-reset form button');
andThen(() => {
assert.notOk(exists(".alert-error"), "hides error");
assert.ok(exists("#new-account-password"), "shows the input");
});
fillIn('.password-reset input', 'perf3ctly5ecur3');
click('.password-reset form button');
andThen(() => {
assert.ok(!exists(".password-reset form"), "form is gone");
});
});

View File

@ -1,5 +1,27 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("User Preferences", { loggedIn: true });
acceptance("User Preferences", {
loggedIn: true,
beforeEach() {
const response = (object) => {
return [
200,
{"Content-Type": "application/json"},
object
];
};
server.post('/u/second_factors.json', () => { //eslint-disable-line
return response({
key: "rcyryaqage3jexfj",
qr: '<div id="test-qr">qr-code</div>'
});
});
server.put('/u/second_factor.json', () => { //eslint-disable-line
return response({ error: 'invalid token' });
});
}
});
QUnit.test("update some fields", assert => {
visit("/u/eviltrout/preferences");
@ -73,3 +95,29 @@ QUnit.test("email", assert => {
assert.equal(find('.tip.bad').text().trim(), I18n.t('user.email.invalid'), 'it should display invalid email tip');
});
});
QUnit.test("second factor", assert => {
visit("/u/eviltrout/preferences/second-factor");
andThen(() => {
assert.ok(exists("#password"), "it has a password input");
});
fillIn('#password', 'secrets');
click(".user-content .btn-primary");
andThen(() => {
assert.ok(exists("#test-qr"), "shows qr code");
assert.notOk(exists("#password"), "it hides the password input");
});
fillIn("#second-factor-token", '111111');
click('.btn-primary');
andThen(() => {
assert.ok(
find(".alert-error").html().indexOf("invalid token") > -1,
"shows server validation error message"
);
});
});

View File

@ -76,6 +76,33 @@ QUnit.test("sign in - not activated - edit email", assert => {
});
});
QUnit.test("second factor", assert => {
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
fillIn('#login-account-name', 'eviltrout');
fillIn('#login-account-password', 'need-second-factor');
click('.modal-footer .btn-primary');
andThen(() => {
assert.not(exists('#modal-alert:visible'), 'it hides the login error');
assert.not(exists('#credentials:visible'), 'it hides the username and password prompt');
assert.ok(exists('#second-factor:visible'), 'it displays the second factor prompt');
assert.not(exists('.modal-footer .btn-primary:disabled'), "enables the login button");
});
fillIn('#login-second-factor', '123456');
click('.modal-footer .btn-primary');
andThen(() => {
assert.ok(exists('.modal-footer .btn-primary:disabled'), "disables the login button");
});
});
QUnit.test("create account", assert => {
visit("/");
click("header .sign-up-button");
@ -106,4 +133,4 @@ QUnit.test("create account", assert => {
andThen(() => {
assert.ok(exists('.modal-footer .btn-primary:disabled'), "create account is disabled");
});
});
});

View File

@ -227,6 +227,17 @@ export default function() {
current_email: 'current@example.com' });
}
if (data.password === 'need-second-factor') {
if (data.second_factor_token) {
return response({ username: 'eviltrout' });
}
return response({ error: "Invalid Second Factor",
reason: "invalid_second_factor",
sent_to_email: 'eviltrout@example.com',
current_email: 'current@example.com' });
}
return response(400, {error: 'invalid login'});
});