Improving user locked out logic.

This patch improves on the user locked
out logic by providing a helping message
near locked out. This would help reduce
retries by giving user the option to use
password reset flow to fix the issue.

PROD-1505
This commit is contained in:
Adeel Khan
2020-05-06 15:41:13 +05:00
parent adc405ae5c
commit 2383fb3fa6
10 changed files with 160 additions and 12 deletions

View File

@@ -970,6 +970,16 @@ class LoginFailures(models.Model):
record.save()
@classmethod
def check_user_reset_password_threshold(cls, user):
"""
Checks if the user is above threshold for reset password message.
"""
record, _ = LoginFailures.objects.get_or_create(user=user)
max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED
return record.failure_count >= max_failures_allowed / 2, record.failure_count
@classmethod
def clear_lockout_counter(cls, user):
"""

View File

@@ -193,11 +193,11 @@
expect($('.button-oa2-facebook')).toBeVisible();
});
it('displays a link to the password reset form', function() {
it('displays a link to the signin help', function() {
createLoginView(this);
// Verify that the password reset link is displayed
expect($('.forgot-password')).toBeVisible();
// Verify that the Signin help link is displayed
expect($('.login-help')).toBeVisible();
});
it('displays a link to the enterprise slug login', function() {

View File

@@ -105,6 +105,13 @@
expect($(view.el).html().length).toEqual(0);
});
it('displays a link to the password reset help', function() {
createPasswordResetView(this);
// Verify that the password reset help link is displayed
expect($('.reset-help')).toBeVisible();
});
it('validates the email field', function() {
createPasswordResetView(this);

View File

@@ -264,6 +264,12 @@
// Load the form. Institution login is always refreshed since it changes based on the previous form.
if (!this.form.isLoaded($form) || type == 'institution_login') {
// We need a special case for loading reset form as there is mismatch of form id
// value ie 'password-reset' vs load function name ie 'reset'
if (type === 'password-reset') {
type = 'reset';
}
this.loadForm(type);
}
this.activeForm = type;

View File

@@ -144,12 +144,16 @@
$form = this.$form,
elements = $form[0].elements,
i,
$n,
tpl,
len = elements.length,
$el,
$label,
key = '',
errors = [],
validation = {};
validation = {},
$desc,
$validationNode;
for (i = 0; i < len; i++) {
$el = $(elements[i]);
@@ -163,12 +167,29 @@
}
if (key) {
if (this.interesting_fields($el)) {
this.remove_validation_error($el, $form);
}
validation = this.validate(elements[i]);
if (validation.isValid) {
obj[key] = $el.attr('type') === 'checkbox' ? $el.is(':checked') : $el.val();
$el.removeClass('error');
$label.removeClass('error');
} else {
if (this.interesting_fields($el)) {
$validationNode = this.get_error_validation_node($el, $form);
if ($validationNode) {
$n = $.parseHTML(validation.message);
tpl = HtmlUtils.template('<i class="fa fa-exclamation-triangle"></i>');
HtmlUtils.prepend($n, tpl());
HtmlUtils.append($validationNode, HtmlUtils.HTML($n));
}
$desc = $form.find('#' + $el.attr('id') + '-desc');
$desc.remove();
}
errors.push(validation.message);
$el.addClass('error');
$label.addClass('error');
@@ -180,6 +201,34 @@
return obj;
},
remove_validation_error: function($el, $form) {
var $validationNode = this.get_error_validation_node($el, $form);
if ($validationNode && $validationNode.find('li').length > 0) {
$validationNode.empty();
}
},
get_error_validation_node: function($el, $form) {
var $node = $form.find('#' + $el.attr('id') + '-validation-error-msg');
return $node.find('ul');
},
interesting_fields: function($el) {
return ($el.attr('name') === 'email' || $el.attr('name') === 'password');
},
toggleHelp: function(event, $help) {
var $el = $(event.currentTarget);
var $i = $el.find('i');
if ($help.css('display') === 'block') {
$help.css('display', 'none');
$i.addClass('fa-caret-right').removeClass('fa-caret-down');
} else {
$help.css('display', 'block');
$i.addClass('fa-caret-down').removeClass('fa-caret-right');
}
},
saveError: function(error) {
this.errors = [

View File

@@ -24,7 +24,8 @@
'click .js-login': 'submitForm',
'click .forgot-password': 'forgotPassword',
'click .login-provider': 'thirdPartyAuth',
'click .enterprise-login': 'enterpriseSlugLogin'
'click .enterprise-login': 'enterpriseSlugLogin',
'click .login-help': 'toggleLoginHelp'
},
formType: 'login',
requiredStr: '',
@@ -139,6 +140,13 @@
this.clearPasswordResetSuccess();
},
toggleLoginHelp: function(event) {
var $help;
event.preventDefault();
$help = $('#login-help');
this.toggleHelp(event, $help);
},
enterpriseSlugLogin: function(event) {
event.preventDefault();
if (this.enterpriseSlugLoginURL) {

View File

@@ -11,7 +11,8 @@
tpl: '#password_reset-tpl',
events: {
'click .js-reset': 'submitForm'
'click .js-reset': 'submitForm',
'click .reset-help': 'toggleResetHelp'
},
formType: 'password-reset',
@@ -27,6 +28,13 @@
this.listenTo(this.model, 'sync', this.saveSuccess);
},
toggleResetHelp: function(event) {
var $help;
event.preventDefault();
$help = $('#reset-help');
this.toggleHelp(event, $help);
},
saveSuccess: function() {
this.trigger('password-email-sent');

View File

@@ -98,6 +98,16 @@
}
}
#login-help, #reset-help {
padding-left: 8px;
}
ul.fa-ul{
margin: 0 0 0 0;
i {
margin-right: 5px;
}
}
.login-register {
$grid-columns: 12;
@@ -373,8 +383,8 @@
@extend %t-copy-sub2;
display: block;
margin-bottom: ($baseline/2);
margin-top: ($baseline/4);
margin-bottom: 5px;
margin-top: 5px;
border: none;
padding: 0;
background: transparent;
@@ -391,6 +401,11 @@
&:focus {
text-decoration: underline;
}
> i {
border: none;
padding: 0;
margin: 0 2px 0 0;
}
}
input,

View File

@@ -127,13 +127,24 @@
<span id="<%- form %>-<%- name %>-validation-error" class="tip error" aria-live="assertive">
<span class="sr-only"></span>
<span id="<%- form %>-<%- name %>-validation-error-msg"></span>
<span id="<%- form %>-<%- name %>-validation-error-msg"><ul class="fa-ul"></ul></span>
</span>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%- form %>-<%- name %>-desc"><%- instructions %></span><% } %>
<% } %>
<% if( form === 'login' && name === 'password' ) { %>
<button type="button" class="forgot-password field-link"><%- gettext("Need help logging in?") %></button>
<button type="button" class="login-help field-link"><i class="fa fa-caret-right" /><%- gettext("Need help signing in?") %></button>
<div id="login-help" style="display:none">
<button type="button" class="field-link form-toggle" data-type="password-reset"><%- gettext("Forgot my password") %></button>
<span><a class="field-link" href="https://support.edx.org/hc/en-us/sections/115004153367-Solve-a-Sign-in-Problem"><%- gettext("Other sign-in issues") %></a></span>
</div>
<button type="button" class="enterprise-login field-link"><%- gettext("Sign in with your company or school") %></button>
<% } %>
<% if( form === 'password-reset' && name === 'email' ) { %>
<button type="button" class="reset-help field-link" ><i class="fa fa-caret-right" /><%- gettext("Need other help signing in?") %></button>
<div id="reset-help" style="display:none">
<button type="button" class="field-link form-toggle" data-type="register"><%- gettext("Create an account") %></button>
<span><a class="field-link" href="https://support.edx.org/hc/en-us/sections/115004153367-Solve-a-Sign-in-Problem"><%- gettext("Other sign-in issues") %></a></span>
</div>
<% } %>
</div>

View File

@@ -113,8 +113,21 @@ def _check_excessive_login_attempts(user):
"""
if user and LoginFailures.is_feature_enabled():
if LoginFailures.is_user_locked_out(user):
raise AuthFailedError(_('This account has been temporarily locked due '
'to excessive login failures. Try again later.'))
_generate_locked_out_error_message()
def _generate_locked_out_error_message():
locked_out_period_in_sec = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS
raise AuthFailedError(Text(_('To protect your account, its been temporarily '
'locked. Try again in {locked_out_period} minutes.'
'{li_start}To be on the safe side, you can reset your '
'password {link_start}here{link_end} before you try again.')).format(
link_start=HTML('<a "#login" class="form-toggle" data-type="password-reset">'),
link_end=HTML('</a>'),
li_start=HTML('<li>'),
li_end=HTML('</li>'),
locked_out_period=int(locked_out_period_in_sec / 60)))
def _enforce_password_policy_compliance(request, user):
@@ -230,6 +243,27 @@ def _handle_failed_authentication(user, authenticated_user):
else:
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(user.email))
if user and LoginFailures.is_feature_enabled():
blocked_threshold, failure_count = LoginFailures.check_user_reset_password_threshold(user)
if blocked_threshold:
if not LoginFailures.is_user_locked_out(user):
max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED
remaining_attempts = max_failures_allowed - failure_count
raise AuthFailedError(Text(_('Email or password is incorrect.'
'{li_start}You have {remaining_attempts} more sign-in '
'attempts before your account is temporarily locked.{li_end}'
'{li_start}If you\'ve forgotten your password, click '
'{link_start}here{link_end} to reset.{li_end}'
))
.format(
link_start=HTML('<a http="#login" class="form-toggle" data-type="password-reset">'),
link_end=HTML('</a>'),
li_start=HTML('<li>'),
li_end=HTML('</li>'),
remaining_attempts=remaining_attempts))
else:
_generate_locked_out_error_message()
raise AuthFailedError(_('Email or password is incorrect.'))