misc: add password strength meter in NewPasswordInput (#63831)
This commit is contained in:
parent
f372225e6d
commit
5de83a83d2
|
@ -55,6 +55,7 @@ urlpatterns = [
|
|||
url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'),
|
||||
url(r'^check-api-client/$', api_views.check_api_client, name='a2-api-check-api-client'),
|
||||
url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'),
|
||||
url(r'^password-strength/$', api_views.password_strength, name='a2-api-password-strength'),
|
||||
url(r'^address-autocomplete/$', api_views.address_autocomplete, name='a2-api-address-autocomplete'),
|
||||
]
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ from .apps.journal.models import Event
|
|||
from .custom_user.models import Profile, ProfileType, User
|
||||
from .journal_event_types import UserLogin, UserRegistration
|
||||
from .models import APIClient, Attribute, PasswordReset, Service
|
||||
from .passwords import get_password_checker
|
||||
from .passwords import get_password_checker, get_password_strength
|
||||
from .utils import misc as utils_misc
|
||||
from .utils.api import DjangoRBACPermission, NaturalKeyRelatedField
|
||||
from .utils.lookups import Unaccent
|
||||
|
@ -1480,6 +1480,29 @@ class ValidatePasswordAPI(BaseRpcView):
|
|||
validate_password = ValidatePasswordAPI.as_view()
|
||||
|
||||
|
||||
class PasswordStrengthSerializer(serializers.Serializer):
|
||||
password = serializers.CharField(required=True, allow_blank=True)
|
||||
|
||||
|
||||
class PasswordStrengthAPI(BaseRpcView):
|
||||
permission_classes = ()
|
||||
authentication_classes = (CsrfExemptSessionAuthentication,)
|
||||
serializer_class = PasswordStrengthSerializer
|
||||
|
||||
def rpc(self, request, serializer):
|
||||
report = get_password_strength(serializer.validated_data['password'])
|
||||
result = {
|
||||
'result': 1,
|
||||
'strength': report.strength,
|
||||
'strength_label': report.strength_label,
|
||||
'hint': report.hint,
|
||||
}
|
||||
return result, status.HTTP_200_OK
|
||||
|
||||
|
||||
password_strength = PasswordStrengthAPI.as_view()
|
||||
|
||||
|
||||
class AddressAutocompleteAPI(APIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
|
||||
|
|
|
@ -273,6 +273,12 @@ class PasswordInput(BasePasswordInput):
|
|||
class NewPasswordInput(PasswordInput):
|
||||
template_name = 'authentic2/widgets/new_password.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
|
||||
if min_strength:
|
||||
self.attrs['data-min-strength'] = min_strength
|
||||
|
||||
def get_context(self, *args, **kwargs):
|
||||
context = super().get_context(*args, **kwargs)
|
||||
password_checker = get_password_checker()
|
||||
|
|
|
@ -55,9 +55,6 @@ class PasswordChecker(metaclass=abc.ABCMeta):
|
|||
self.label = label
|
||||
self.result = result
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def __call__(self, password, **kwargs):
|
||||
"""Return an iterable of Check objects giving the list of checks and
|
||||
|
@ -90,12 +87,7 @@ class DefaultPasswordChecker(PasswordChecker):
|
|||
def regexp_label(self):
|
||||
return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
|
||||
|
||||
@property
|
||||
def min_strength(self):
|
||||
return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
|
||||
|
||||
def __call__(self, password, **kwargs):
|
||||
|
||||
if self.min_length:
|
||||
yield self.Check(
|
||||
result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
|
||||
|
@ -113,20 +105,46 @@ class DefaultPasswordChecker(PasswordChecker):
|
|||
if self.regexp and self.regexp_label:
|
||||
yield self.Check(result=bool(re.match(self.regexp, password)), label=self.regexp_label)
|
||||
|
||||
if self.min_strength:
|
||||
score = 0
|
||||
if password:
|
||||
score = zxcvbn(password)['score']
|
||||
|
||||
yield self.Check(result=score > self.min_strength, label=_('Secure password'))
|
||||
|
||||
|
||||
def get_password_checker(*args, **kwargs):
|
||||
return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
password_checker = get_password_checker()
|
||||
errors = [not check.result for check in password_checker(password)]
|
||||
if any(errors):
|
||||
raise ValidationError(_('This password is not accepted.'))
|
||||
min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
|
||||
if min_strength is not None:
|
||||
if get_password_strength(password).strength < min_strength:
|
||||
raise ValidationError(_('This password is not strong enough.'))
|
||||
|
||||
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
|
||||
if min_length > len(password):
|
||||
raise ValidationError(_('Password must be at least %s characters.') % min_length)
|
||||
else:
|
||||
password_checker = get_password_checker()
|
||||
errors = [not check.result for check in password_checker(password)]
|
||||
if any(errors):
|
||||
raise ValidationError(_('This password is not accepted.'))
|
||||
|
||||
|
||||
class StrengthReport:
|
||||
def __init__(self, strength, hint):
|
||||
self.strength = strength
|
||||
self.strength_label = [_('Very Weak'), _('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
|
||||
self.hint = hint
|
||||
|
||||
|
||||
def get_password_strength(password):
|
||||
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
|
||||
|
||||
hint = _('add more words or characters')
|
||||
strength = 0
|
||||
if min_length and len(password) < min_length:
|
||||
hint = _('use at least %s characters' % min_length)
|
||||
elif password:
|
||||
report = zxcvbn(password)
|
||||
strength = report['score']
|
||||
suggestions = report['feedback']['suggestions']
|
||||
if len(suggestions):
|
||||
hint = report['feedback']['suggestions'][0]
|
||||
|
||||
return StrengthReport(strength, hint)
|
||||
|
|
|
@ -44,6 +44,98 @@
|
|||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Password strength meter & hints */
|
||||
.a2-password-feedback {
|
||||
font-size: 90%;
|
||||
margin-bottom: 1.8em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.a2-password-strength {
|
||||
margin: 0.4rem 0;
|
||||
display: none;
|
||||
|
||||
&--label {
|
||||
display: inline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&--name {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&--gauge {
|
||||
display: flex;
|
||||
height: 0.7rem;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
&--bar {
|
||||
flex-grow: 1;
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.strength-0 &--bar { background: darkred; }
|
||||
&.strength-1 &--bar { background: orange; }
|
||||
&.strength-2 &--bar { background: yellow; }
|
||||
&.strength-3 &--bar { background: yellowgreen; }
|
||||
&.strength-4 &--bar { background: darkgreen; }
|
||||
|
||||
@for $i from 0 through 4 {
|
||||
&.strength-#{$i} { display: block; }
|
||||
&.strength-#{$i} &--bar:nth-child(n+#{$i + 2}) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.a2-password-hint {
|
||||
font-size: 90%;
|
||||
|
||||
&.a2-password-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--text {
|
||||
margin: 0.4rem 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
display: none;
|
||||
.a2-password-ok & {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
&--nok{
|
||||
display: inline;
|
||||
.a2-password-ok & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--content {
|
||||
font-weight: bold;
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch password feedback to strength meter if data-min-strength is defined
|
||||
// on the password input
|
||||
input[type=password][data-min-strength] {
|
||||
& ~ .a2-password-feedback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& ~ .a2-password-policy-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Equality check */
|
||||
|
||||
.a2-password-nok .a2-password-check-equality-default,
|
||||
|
|
|
@ -25,6 +25,37 @@ a2_password_check_equality = (function () {
|
|||
}
|
||||
})();
|
||||
|
||||
function update_password_strength($input, password, min_strength) {
|
||||
var $feedback = $input.parent().find('.a2-password-feedback')
|
||||
var $hint = $feedback.find('.a2-password-hint');
|
||||
var $hint_content = $feedback.find('.a2-password-hint--content');
|
||||
var $strength = $feedback.find('.a2-password-strength');
|
||||
var $strength_name = $feedback.find('.a2-password-strength--name');
|
||||
$.ajax({
|
||||
method: 'POST',
|
||||
url: '/api/password-strength/',
|
||||
data: JSON.stringify({'password': password}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
success: function(data) {
|
||||
strength = data.strength
|
||||
ok = strength >= min_strength
|
||||
$hint.toggleClass('a2-password-ok', ok);
|
||||
$hint.toggleClass('a2-password-hidden', password == '' || strength == 4);
|
||||
$hint.toggleClass('errornotice', !ok);
|
||||
$hint.toggleClass('infonotice', ok);
|
||||
$hint_content.text(data.hint)
|
||||
|
||||
for (var i = 0; i < 5; ++i) {
|
||||
$strength.removeClass('strength-' + i);
|
||||
}
|
||||
|
||||
$strength.addClass('strength-' + strength);
|
||||
$strength_name.text(data.strength_label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
a2_password_validate = (function () {
|
||||
function toggle_error($elt) {
|
||||
$elt.removeClass('a2-password-check-equality-ok');
|
||||
|
@ -36,6 +67,13 @@ a2_password_validate = (function () {
|
|||
}
|
||||
function get_validation($input) {
|
||||
var password = $input.val();
|
||||
var min_strength = $input.attr('data-min-strength')
|
||||
|
||||
if( min_strength !== undefined ) {
|
||||
update_password_strength($input, password, min_strength);
|
||||
return
|
||||
}
|
||||
|
||||
var $help_text = $input.parent().find('.a2-password-policy-hint');
|
||||
var $policyContainer = $help_text.find('.a2-password-policy-container');
|
||||
$.ajax({
|
||||
|
|
|
@ -1,5 +1,30 @@
|
|||
{% load i18n %}
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
<div class="a2-password-feedback" data-min-strength="{{ min_strength }}">
|
||||
<div class="a2-password-strength strength-0">
|
||||
<p class="a2-password-strength--label">{% trans "Password strength :" %}
|
||||
<div class="a2-password-strength--name">-</div>
|
||||
</p>
|
||||
<div class="a2-password-strength--gauge">
|
||||
<div class="a2-password-strength--bar"></div>
|
||||
<div class="a2-password-strength--bar"></div>
|
||||
<div class="a2-password-strength--bar"></div>
|
||||
<div class="a2-password-strength--bar"></div>
|
||||
<div class="a2-password-strength--bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a2-password-hint errornotice a2-password-hidden">
|
||||
<div class="a2-password-hint--text">
|
||||
<div class="a2-password-hint--nok">
|
||||
{% trans "Your password is too weak. To create a secure password, please " %}
|
||||
</div>
|
||||
<div class="a2-password-hint--ok">
|
||||
{% trans "Your password is strong enough. To create an even more secure password, you could " %}
|
||||
</div>
|
||||
<div class="a2-password-hint--content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a2-password-policy-hint">
|
||||
{% trans "In order to create a secure password, please use at least :" %}
|
||||
<div class="a2-password-policy-container">
|
||||
|
|
|
@ -1756,21 +1756,36 @@ def test_validate_password_regex(app, settings):
|
|||
assert response.json['checks'][4]['result'] is True
|
||||
|
||||
|
||||
def test_validate_password_strength(app, settings):
|
||||
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 2
|
||||
response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
|
||||
@pytest.mark.parametrize(
|
||||
'password,strength,label',
|
||||
[
|
||||
('?', 0, 'Very Weak'),
|
||||
('?JR!', 1, 'Weak'),
|
||||
('?JR!p4A', 2, 'Fair'),
|
||||
('?JR!p4A2i', 3, 'Good'),
|
||||
('?JR!p4A2i:#', 4, 'Strong'),
|
||||
],
|
||||
)
|
||||
def test_password_strength(app, settings, password, strength, label):
|
||||
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
|
||||
response = app.post_json('/api/password-strength/', params={'password': password})
|
||||
assert response.json['result'] == 1
|
||||
assert response.json['ok'] is False
|
||||
assert len(response.json['checks']) == 5
|
||||
assert response.json['checks'][4]['label'] == 'Secure password'
|
||||
assert response.json['checks'][4]['result'] is False
|
||||
assert response.json['strength'] == strength
|
||||
assert response.json['strength_label'] == label
|
||||
|
||||
response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
|
||||
|
||||
def test_password_strength_min_length(app, settings):
|
||||
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 10
|
||||
|
||||
response = app.post_json('/api/password-strength/', params={'password': 'too_short'})
|
||||
assert response.json['result'] == 1
|
||||
assert response.json['ok'] is True
|
||||
assert len(response.json['checks']) == 5
|
||||
assert response.json['checks'][4]['label'] == 'Secure password'
|
||||
assert response.json['checks'][4]['result'] is True
|
||||
assert response.json['strength'] == 0
|
||||
assert response.json['strength_label'] == 'Very Weak'
|
||||
|
||||
response = app.post_json('/api/password-strength/', params={'password': 'long_enough'})
|
||||
assert response.json['result'] == 1
|
||||
assert response.json['strength'] != 0
|
||||
assert response.json['strength_label'] != 'Very Weak'
|
||||
|
||||
|
||||
def test_api_users_get_or_create(settings, app, admin):
|
||||
|
|
|
@ -24,7 +24,7 @@ from django.core.exceptions import ValidationError
|
|||
from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
|
||||
|
||||
|
||||
def test_validate_password():
|
||||
def test_validate_password(settings):
|
||||
with pytest.raises(ValidationError):
|
||||
validate_password('aaaaaZZZZZZ')
|
||||
with pytest.raises(ValidationError):
|
||||
|
@ -34,6 +34,33 @@ def test_validate_password():
|
|||
validate_password('000aaaaZZZZ')
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'password,min_strength',
|
||||
[
|
||||
('', 0),
|
||||
('?', 0),
|
||||
('?JR!', 1),
|
||||
('?JR!p4A', 2),
|
||||
('?JR!p4A2i', 3),
|
||||
('?JR!p4A2i:#', 4),
|
||||
],
|
||||
)
|
||||
def test_validate_password_strength(settings, password, min_strength):
|
||||
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
|
||||
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
|
||||
validate_password(password)
|
||||
|
||||
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
|
||||
with pytest.raises(ValidationError):
|
||||
validate_password(password)
|
||||
|
||||
if min_strength < 4:
|
||||
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
|
||||
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
|
||||
with pytest.raises(ValidationError):
|
||||
validate_password(password)
|
||||
|
||||
|
||||
def test_validate_colour():
|
||||
validator = HexaColourValidator()
|
||||
with pytest.raises(ValidationError):
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
from pyquery import PyQuery
|
||||
|
||||
from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, TimeWidget
|
||||
from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, NewPasswordInput, TimeWidget
|
||||
|
||||
|
||||
def test_datetimepicker_init_and_render_no_locale():
|
||||
|
@ -50,3 +50,20 @@ def test_datalisttextinput_init_and_render():
|
|||
assert option.values()[0] in data
|
||||
data.remove(option.values()[0])
|
||||
assert not data
|
||||
|
||||
|
||||
def test_new_password_input(settings):
|
||||
widget = NewPasswordInput()
|
||||
html = widget.render('foo', 'bar')
|
||||
query = PyQuery(html)
|
||||
|
||||
textinput = query.find('input')
|
||||
assert textinput.attr('data-min-strength') is None
|
||||
|
||||
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
|
||||
widget = NewPasswordInput()
|
||||
html = widget.render('foo', 'bar')
|
||||
query = PyQuery(html)
|
||||
|
||||
textinput = query.find('input')
|
||||
assert textinput.attr('data-min-strength') == '3'
|
||||
|
|
Loading…
Reference in New Issue