misc: add password strength meter in NewPasswordInput (#63831)

This commit is contained in:
Corentin Sechet 2022-09-01 00:55:49 +02:00
parent f372225e6d
commit 5de83a83d2
10 changed files with 296 additions and 34 deletions

View File

@ -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'),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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