Projet

Général

Profil

0002-misc-add-password-strength-meter-in-NewPasswordInput.patch

Corentin Séchet, 07 septembre 2022 13:34

Télécharger (17,7 ko)

Voir les différences:

Subject: [PATCH 2/3] misc: add password strength meter in NewPasswordInput
 (#63831)

 src/authentic2/api_urls.py                    |  1 +
 src/authentic2/api_views.py                   | 25 ++++-
 src/authentic2/forms/widgets.py               |  6 ++
 src/authentic2/passwords.py                   | 56 +++++++----
 .../static/authentic2/css/password.scss       | 92 +++++++++++++++++++
 .../static/authentic2/js/password.js          | 38 ++++++++
 .../authentic2/widgets/new_password.html      | 25 +++++
 tests/api/test_all.py                         | 39 +++++---
 tests/test_validators.py                      | 29 +++++-
 tests/test_widgets.py                         | 19 +++-
 10 files changed, 296 insertions(+), 34 deletions(-)
src/authentic2/api_urls.py
55 55
    url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'),
56 56
    url(r'^check-api-client/$', api_views.check_api_client, name='a2-api-check-api-client'),
57 57
    url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'),
58
    url(r'^password-strength/$', api_views.password_strength, name='a2-api-password-strength'),
58 59
    url(r'^address-autocomplete/$', api_views.address_autocomplete, name='a2-api-address-autocomplete'),
59 60
]
60 61

  
src/authentic2/api_views.py
61 61
from .custom_user.models import Profile, ProfileType, User
62 62
from .journal_event_types import UserLogin, UserRegistration
63 63
from .models import APIClient, Attribute, PasswordReset, Service
64
from .passwords import get_password_checker
64
from .passwords import get_password_checker, get_password_strength
65 65
from .utils import misc as utils_misc
66 66
from .utils.api import DjangoRBACPermission, NaturalKeyRelatedField
67 67
from .utils.lookups import Unaccent
......
1480 1480
validate_password = ValidatePasswordAPI.as_view()
1481 1481

  
1482 1482

  
1483
class PasswordStrengthSerializer(serializers.Serializer):
1484
    password = serializers.CharField(required=True, allow_blank=True)
1485

  
1486

  
1487
class PasswordStrengthAPI(BaseRpcView):
1488
    permission_classes = ()
1489
    authentication_classes = (CsrfExemptSessionAuthentication,)
1490
    serializer_class = PasswordStrengthSerializer
1491

  
1492
    def rpc(self, request, serializer):
1493
        report = get_password_strength(serializer.validated_data['password'])
1494
        result = {
1495
            'result': 1,
1496
            'strength': report.strength,
1497
            'strength_label': report.strength_label,
1498
            'hint': report.hint,
1499
        }
1500
        return result, status.HTTP_200_OK
1501

  
1502

  
1503
password_strength = PasswordStrengthAPI.as_view()
1504

  
1505

  
1483 1506
class AddressAutocompleteAPI(APIView):
1484 1507
    permission_classes = (permissions.AllowAny,)
1485 1508

  
src/authentic2/forms/widgets.py
273 273
class NewPasswordInput(PasswordInput):
274 274
    template_name = 'authentic2/widgets/new_password.html'
275 275

  
276
    def __init__(self, *args, **kwargs):
277
        super().__init__(*args, **kwargs)
278
        min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
279
        if min_strength:
280
            self.attrs['data-min-strength'] = min_strength
281

  
276 282
    def get_context(self, *args, **kwargs):
277 283
        context = super().get_context(*args, **kwargs)
278 284
        password_checker = get_password_checker()
src/authentic2/passwords.py
55 55
            self.label = label
56 56
            self.result = result
57 57

  
58
    def __init__(self, *args, **kwargs):
59
        pass
60

  
61 58
    @abc.abstractmethod
62 59
    def __call__(self, password, **kwargs):
63 60
        """Return an iterable of Check objects giving the list of checks and
......
90 87
    def regexp_label(self):
91 88
        return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
92 89

  
93
    @property
94
    def min_strength(self):
95
        return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
96

  
97 90
    def __call__(self, password, **kwargs):
98

  
99 91
        if self.min_length:
100 92
            yield self.Check(
101 93
                result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
......
113 105
        if self.regexp and self.regexp_label:
114 106
            yield self.Check(result=bool(re.match(self.regexp, password)), label=self.regexp_label)
115 107

  
116
        if self.min_strength:
117
            score = 0
118
            if password:
119
                score = zxcvbn(password)['score']
120

  
121
            yield self.Check(result=score > self.min_strength, label=_('Secure password'))
122

  
123 108

  
124 109
def get_password_checker(*args, **kwargs):
125 110
    return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
126 111

  
127 112

  
128 113
def validate_password(password):
129
    password_checker = get_password_checker()
130
    errors = [not check.result for check in password_checker(password)]
131
    if any(errors):
132
        raise ValidationError(_('This password is not accepted.'))
114
    min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
115
    if min_strength is not None:
116
        if get_password_strength(password).strength < min_strength:
117
            raise ValidationError(_('This password is not strong enough.'))
118

  
119
        min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
120
        if min_length > len(password):
121
            raise ValidationError(_('Password must be at least %s characters.') % min_length)
122
    else:
123
        password_checker = get_password_checker()
124
        errors = [not check.result for check in password_checker(password)]
125
        if any(errors):
126
            raise ValidationError(_('This password is not accepted.'))
127

  
128

  
129
class StrengthReport:
130
    def __init__(self, strength, hint):
131
        self.strength = strength
132
        self.strength_label = [_('Very Weak'), _('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
133
        self.hint = hint
134

  
135

  
136
def get_password_strength(password):
137
    min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
138

  
139
    hint = _('add more words or characters')
140
    strength = 0
141
    if min_length and len(password) < min_length:
142
        hint = _('use at least %s characters' % min_length)
143
    elif password:
144
        report = zxcvbn(password)
145
        strength = report['score']
146
        suggestions = report['feedback']['suggestions']
147
        if len(suggestions):
148
            hint = report['feedback']['suggestions'][0]
149

  
150
    return StrengthReport(strength, hint)
src/authentic2/static/authentic2/css/password.scss
44 44
  visibility: visible;
45 45
}
46 46

  
47
/* Password strength meter & hints */
48
.a2-password-feedback {
49
  font-size: 90%;
50
  margin-bottom: 1.8em;
51
  display: none;
52
}
53

  
54
.a2-password-strength {
55
  margin: 0.4rem 0;
56
  display: none;
57

  
58
  &--label {
59
    display: inline;
60
    font-weight: bold;
61
  }
62

  
63
  &--name {
64
    display: inline;
65
  }
66

  
67
  &--gauge {
68
    display: flex;
69
    height: 0.7rem;
70
    margin: 0.2rem 0;
71
  }
72

  
73
  &--bar {
74
    flex-grow: 1;
75
    &:not(:last-child) {
76
      margin-right: 0.4rem;
77
    }
78
  }
79

  
80
  &.strength-0 &--bar { background: darkred; }
81
  &.strength-1 &--bar { background: orange; }
82
  &.strength-2 &--bar { background: yellow; }
83
  &.strength-3 &--bar { background: yellowgreen; }
84
  &.strength-4 &--bar { background: darkgreen; }
85

  
86
  @for $i from 0 through 4 {
87
    &.strength-#{$i} { display: block; }
88
    &.strength-#{$i} &--bar:nth-child(n+#{$i + 2}) {
89
      background: transparent;
90
    }
91
  }
92
}
93

  
94
.a2-password-hint {
95
  font-size: 90%;
96

  
97
  &.a2-password-hidden {
98
    display: none;
99
  }
100

  
101
  &--text {
102
    margin: 0.4rem 0;
103
    display: inline-block;
104
  }
105

  
106
  &--ok {
107
    display: none;
108
    .a2-password-ok & {
109
      display: inline;
110
    }
111
  }
112

  
113
  &--nok{
114
    display: inline;
115
    .a2-password-ok & {
116
      display: none;
117
    }
118
  }
119

  
120
  &--content {
121
    font-weight: bold;
122
    display: inline;
123
  }
124
}
125

  
126
// Switch password feedback to strength meter if data-min-strength is defined
127
// on the password input
128
input[type=password][data-min-strength] {
129
  & ~ .a2-password-feedback {
130
    display: block;
131
  }
132

  
133
  & ~ .a2-password-policy-hint {
134
    display: none;
135
  }
136
}
137

  
138

  
47 139
/* Equality check */
48 140

  
49 141
.a2-password-nok .a2-password-check-equality-default,
src/authentic2/static/authentic2/js/password.js
25 25
    }
26 26
})();
27 27

  
28
function update_password_strength($input, password, min_strength) {
29
    var $feedback = $input.parent().find('.a2-password-feedback')
30
    var $hint = $feedback.find('.a2-password-hint');
31
    var $hint_content = $feedback.find('.a2-password-hint--content');
32
    var $strength = $feedback.find('.a2-password-strength');
33
    var $strength_name = $feedback.find('.a2-password-strength--name');
34
    $.ajax({
35
        method: 'POST',
36
        url: '/api/password-strength/',
37
        data: JSON.stringify({'password': password}),
38
        dataType: 'json',
39
        contentType: 'application/json; charset=utf-8',
40
        success: function(data) {
41
            strength = data.strength
42
            ok = strength >= min_strength
43
            $hint.toggleClass('a2-password-ok', ok);
44
            $hint.toggleClass('a2-password-hidden', password == '' || strength == 4);
45
            $hint.toggleClass('errornotice', !ok);
46
            $hint.toggleClass('infonotice', ok);
47
            $hint_content.text(data.hint)
48

  
49
            for (var i = 0; i < 5; ++i) {
50
                $strength.removeClass('strength-' + i);
51
            }
52

  
53
            $strength.addClass('strength-' + strength);
54
            $strength_name.text(data.strength_label);
55
        }
56
    });
57
}
58

  
28 59
a2_password_validate = (function () {
29 60
    function toggle_error($elt) {
30 61
        $elt.removeClass('a2-password-check-equality-ok');
......
36 67
    }
37 68
    function get_validation($input) {
38 69
        var password = $input.val();
70
        var min_strength = $input.attr('data-min-strength')
71

  
72
        if( min_strength !== undefined ) {
73
            update_password_strength($input, password, min_strength);
74
            return
75
        }
76

  
39 77
        var $help_text = $input.parent().find('.a2-password-policy-hint');
40 78
        var $policyContainer = $help_text.find('.a2-password-policy-container');
41 79
        $.ajax({
src/authentic2/templates/authentic2/widgets/new_password.html
1 1
{% load i18n %}
2 2
{% include "django/forms/widgets/input.html" %}
3
<div class="a2-password-feedback" data-min-strength="{{ min_strength }}">
4
  <div class="a2-password-strength strength-0">
5
    <p class="a2-password-strength--label">{% trans "Password strength :" %}
6
    <div class="a2-password-strength--name">-</div>
7
    </p>
8
    <div class="a2-password-strength--gauge">
9
      <div class="a2-password-strength--bar"></div>
10
      <div class="a2-password-strength--bar"></div>
11
      <div class="a2-password-strength--bar"></div>
12
      <div class="a2-password-strength--bar"></div>
13
      <div class="a2-password-strength--bar"></div>
14
    </div>
15
  </div>
16
  <div class="a2-password-hint errornotice a2-password-hidden">
17
    <div class="a2-password-hint--text">
18
      <div class="a2-password-hint--nok">
19
        {% trans "Your password is too weak. To create a secure password, please " %}
20
      </div>
21
      <div class="a2-password-hint--ok">
22
        {% trans "Your password is strong enough. To create an even more secure password, you could " %}
23
      </div>
24
      <div class="a2-password-hint--content"></div>
25
    </div>
26
  </div>
27
</div>
3 28
<div class="a2-password-policy-hint">
4 29
  {% trans "In order to create a secure password, please use at least :" %}
5 30
  <div class="a2-password-policy-container">
tests/api/test_all.py
1756 1756
    assert response.json['checks'][4]['result'] is True
1757 1757

  
1758 1758

  
1759
def test_validate_password_strength(app, settings):
1760
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 2
1761
    response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
1759
@pytest.mark.parametrize(
1760
    'password,strength,label',
1761
    [
1762
        ('?', 0, 'Very Weak'),
1763
        ('?JR!', 1, 'Weak'),
1764
        ('?JR!p4A', 2, 'Fair'),
1765
        ('?JR!p4A2i', 3, 'Good'),
1766
        ('?JR!p4A2i:#', 4, 'Strong'),
1767
    ],
1768
)
1769
def test_password_strength(app, settings, password, strength, label):
1770
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
1771
    response = app.post_json('/api/password-strength/', params={'password': password})
1762 1772
    assert response.json['result'] == 1
1763
    assert response.json['ok'] is False
1764
    assert len(response.json['checks']) == 5
1765
    assert response.json['checks'][4]['label'] == 'Secure password'
1766
    assert response.json['checks'][4]['result'] is False
1773
    assert response.json['strength'] == strength
1774
    assert response.json['strength_label'] == label
1775

  
1767 1776

  
1768
    response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
1777
def test_password_strength_min_length(app, settings):
1778
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 10
1779

  
1780
    response = app.post_json('/api/password-strength/', params={'password': 'too_short'})
1769 1781
    assert response.json['result'] == 1
1770
    assert response.json['ok'] is True
1771
    assert len(response.json['checks']) == 5
1772
    assert response.json['checks'][4]['label'] == 'Secure password'
1773
    assert response.json['checks'][4]['result'] is True
1782
    assert response.json['strength'] == 0
1783
    assert response.json['strength_label'] == 'Very Weak'
1784

  
1785
    response = app.post_json('/api/password-strength/', params={'password': 'long_enough'})
1786
    assert response.json['result'] == 1
1787
    assert response.json['strength'] != 0
1788
    assert response.json['strength_label'] != 'Very Weak'
1774 1789

  
1775 1790

  
1776 1791
def test_api_users_get_or_create(settings, app, admin):
tests/test_validators.py
24 24
from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
25 25

  
26 26

  
27
def test_validate_password():
27
def test_validate_password(settings):
28 28
    with pytest.raises(ValidationError):
29 29
        validate_password('aaaaaZZZZZZ')
30 30
    with pytest.raises(ValidationError):
......
34 34
    validate_password('000aaaaZZZZ')
35 35

  
36 36

  
37
@pytest.mark.parametrize(
38
    'password,min_strength',
39
    [
40
        ('', 0),
41
        ('?', 0),
42
        ('?JR!', 1),
43
        ('?JR!p4A', 2),
44
        ('?JR!p4A2i', 3),
45
        ('?JR!p4A2i:#', 4),
46
    ],
47
)
48
def test_validate_password_strength(settings, password, min_strength):
49
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
50
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
51
    validate_password(password)
52

  
53
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
54
    with pytest.raises(ValidationError):
55
        validate_password(password)
56

  
57
    if min_strength < 4:
58
        settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
59
        settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
60
        with pytest.raises(ValidationError):
61
            validate_password(password)
62

  
63

  
37 64
def test_validate_colour():
38 65
    validator = HexaColourValidator()
39 66
    with pytest.raises(ValidationError):
tests/test_widgets.py
17 17

  
18 18
from pyquery import PyQuery
19 19

  
20
from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, TimeWidget
20
from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, NewPasswordInput, TimeWidget
21 21

  
22 22

  
23 23
def test_datetimepicker_init_and_render_no_locale():
......
50 50
        assert option.values()[0] in data
51 51
        data.remove(option.values()[0])
52 52
    assert not data
53

  
54

  
55
def test_new_password_input(settings):
56
    widget = NewPasswordInput()
57
    html = widget.render('foo', 'bar')
58
    query = PyQuery(html)
59

  
60
    textinput = query.find('input')
61
    assert textinput.attr('data-min-strength') is None
62

  
63
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
64
    widget = NewPasswordInput()
65
    html = widget.render('foo', 'bar')
66
    query = PyQuery(html)
67

  
68
    textinput = query.find('input')
69
    assert textinput.attr('data-min-strength') == '3'
53
-