Projet

Général

Profil

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

Corentin Séchet, 05 septembre 2022 16:45

Télécharger (14,9 ko)

Voir les différences:

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

 src/authentic2/api_views.py                   | 18 ++++-
 src/authentic2/forms/widgets.py               |  5 +-
 src/authentic2/passwords.py                   | 53 ++++++++++---
 .../static/authentic2/css/password.scss       | 79 +++++++++++++++++++
 .../static/authentic2/js/password.js          | 60 +++++++++++---
 .../authentic2/widgets/new_password.html      | 42 ++++++++--
 tests/api/test_all.py                         | 17 ++--
 7 files changed, 228 insertions(+), 46 deletions(-)
src/authentic2/api_views.py
1462 1462

  
1463 1463
    def rpc(self, request, serializer):
1464 1464
        password_checker = get_password_checker()
1465
        report = password_checker(serializer.validated_data['password'])
1466

  
1467
        if hasattr(report, 'strength'):
1468
            result = {
1469
                'result': 1,
1470
                'ok': report.ok,
1471
                'strength': report.strength,
1472
                'strength_label': report.strength_label,
1473
                'hint': report.hint,
1474
            }
1475
            return result, status.HTTP_200_OK
1476

  
1465 1477
        checks = []
1466
        result = {'result': 1, 'checks': checks}
1478
        result = {'result': 1, 'ok': report.ok, 'checks': checks}
1467 1479
        ok = True
1468
        for check in password_checker(serializer.validated_data['password']):
1480
        for check in report.checks:
1469 1481
            ok = ok and check.result
1470 1482
            checks.append(
1471 1483
                {
......
1473 1485
                    'label': check.label,
1474 1486
                }
1475 1487
            )
1476
        result['ok'] = ok
1488

  
1477 1489
        return result, status.HTTP_200_OK
1478 1490

  
1479 1491

  
src/authentic2/forms/widgets.py
276 276
    def get_context(self, *args, **kwargs):
277 277
        context = super().get_context(*args, **kwargs)
278 278
        password_checker = get_password_checker()
279
        checks = list(password_checker(''))
280
        context['checks'] = checks
279
        report = password_checker('')
280
        context['report'] = report
281
        context['use_password_strength'] = hasattr(report, 'strength')
281 282
        return context
282 283

  
283 284
    def render(self, name, value, attrs=None, renderer=None):
src/authentic2/passwords.py
55 55
            self.label = label
56 56
            self.result = result
57 57

  
58
    def __init__(self, *args, **kwargs):
59
        pass
58
    class ChecksReport:
59
        def __init__(self, checks):
60
            self.checks = list(checks)
61

  
62
        @property
63
        def ok(self):
64
            return all([check.result for check in self.checks])
65

  
66
    class StrengthReport:
67
        def __init__(self, ok, strength, hint):
68
            self.ok = ok
69
            self.strength = strength
70
            if strength is not None:
71
                self.strength_label = [_('Very Weak'), _('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
72
            self.hint = hint
60 73

  
61 74
    @abc.abstractmethod
62 75
    def __call__(self, password, **kwargs):
63 76
        """Return an iterable of Check objects giving the list of checks and
64
        their result."""
77
        their result or a StrengthReport giving password strength and hints
78
        to improve it."""
65 79
        return []
66 80

  
67 81

  
......
95 109
        return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
96 110

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

  
112
        if self.min_strength:
113
            return self.password_strength(password, **kwargs)
114
        else:
115
            return self.ChecksReport(self.checks(password, **kwargs))
116

  
117
    def password_strength(self, password, **kwargs):
118
        hint = ''
119
        strength = 0
120
        if self.min_length and len(password) < self.min_length:
121
            hint = _('use at least 8 characters')
122
        elif password:
123
            report = zxcvbn(password)
124
            strength = report['score']
125
            suggestions = report['feedback']['suggestions']
126
            if len(suggestions):
127
                hint = report['feedback']['suggestions'][0]
128
            else:
129
                hint = _('add more words or characters')
130

  
131
        return self.StrengthReport(strength >= self.min_strength, strength, hint)
132

  
133
    def checks(self, password, **kwargs):
99 134
        if self.min_length:
100 135
            yield self.Check(
101 136
                result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
......
113 148
        if self.regexp and self.regexp_label:
114 149
            yield self.Check(result=bool(re.match(self.regexp, password)), label=self.regexp_label)
115 150

  
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 151

  
124 152
def get_password_checker(*args, **kwargs):
125 153
    return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
......
127 155

  
128 156
def validate_password(password):
129 157
    password_checker = get_password_checker()
130
    errors = [not check.result for check in password_checker(password)]
131
    if any(errors):
158
    if not password_checker(password).ok:
132 159
        raise ValidationError(_('This password is not accepted.'))
src/authentic2/static/authentic2/css/password.scss
14 14
  padding-left: 1.25rem;
15 15
}
16 16

  
17
/* Password classes checks */
17 18
.a2-password-policy-container {
18 19
  display: block;
19 20
  margin-top: 0.6em;
......
44 45
  visibility: visible;
45 46
}
46 47

  
48
/* Password strength meter & hints */
49
.a2-password-feedback {
50
  font-size: 90%;
51
  margin-bottom: 1.8em;
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
  &--to-weak {
107
    display: inline;
108
    .a2-password-ok & {
109
      display: none;
110
    }
111
  }
112

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

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

  
47 126
/* Equality check */
48 127

  
49 128
.a2-password-nok .a2-password-check-equality-default,
src/authentic2/static/authentic2/js/password.js
38 38
        var password = $input.val();
39 39
        var $help_text = $input.parent().find('.a2-password-policy-hint');
40 40
        var $policyContainer = $help_text.find('.a2-password-policy-container');
41

  
42
        function update_checks(data) {
43
            if (! data.result) {
44
                return;
45
            }
46

  
47
            $policyContainer.empty();
48
            $policyContainer.removeClass('a2-password-ok a2-password-nok');
49
            for (var i = 0; i < data.checks.length; i++) {
50
                var error = data.checks[i];
51

  
52
                var $rule = $('<span class="a2-password-policy-rule"/>');
53
                $rule.text(error.label)
54
                $rule.appendTo($policyContainer);
55
                $rule.toggleClass('a2-password-ok', error.result);
56
                $rule.toggleClass('a2-password-nok', ! error.result);
57
            }
58
        }
59

  
60
        var $feedback = $input.parent().find('.a2-password-feedback')
61
        var $hint = $feedback.find('.a2-password-hint');
62
        var $hint_content = $feedback.find('.a2-password-hint--content');
63
        var $strength = $feedback.find('.a2-password-strength');
64
        var $strength_name = $feedback.find('.a2-password-strength--name');
65

  
66

  
67
        function update_strength(data) {
68
            $hint.toggleClass('a2-password-ok', data.ok);
69
            $hint.toggleClass('a2-password-hidden', password == '' || data.strength == 4);
70
            $hint.toggleClass('errornotice', !data.ok);
71
            $hint.toggleClass('infonotice', data.ok);
72
            $hint_content.text(data.hint)
73

  
74
            if(data.strength !== undefined) {
75
                for (var i = 0; i < 5; ++i) {
76
                    $strength.removeClass('strength-' + i);
77
                }
78

  
79
                $strength.addClass('strength-' + data.strength);
80
                $strength_name.text(data.strength_label);
81
            }
82
        }
83

  
41 84
        $.ajax({
42 85
            method: 'POST',
43 86
            url: '/api/validate-password/',
......
45 88
            dataType: 'json',
46 89
            contentType: 'application/json; charset=utf-8',
47 90
            success: function(data) {
48
                if (! data.result) {
49
                    return;
91
                if(data.strength !== undefined) {
92
                    update_strength(data)
50 93
                }
51

  
52
                $policyContainer.empty();
53
                $policyContainer.removeClass('a2-password-ok a2-password-nok');
54
                for (var i = 0; i < data.checks.length; i++) {
55
                    var error = data.checks[i];
56

  
57
                    var $rule = $('<span class="a2-password-policy-rule"/>');
58
                    $rule.text(error.label)
59
                    $rule.appendTo($policyContainer);
60
                    $rule.toggleClass('a2-password-ok', error.result);
61
                    $rule.toggleClass('a2-password-nok', ! error.result);
94
                else {
95
                    update_checks(data)
62 96
                }
63 97
            }
64 98
        });
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-policy-hint">
4
  {% trans "In order to create a secure password, please use at least :" %}
5
  <div class="a2-password-policy-container">
6
    {% for check in checks %}
7
      <span class="a2-password-policy-rule">{{ check.label }}</span>
8
    {% endfor %}
3
{% if use_password_strength %}
4
  <div class="a2-password-feedback">
5
    <div class="a2-password-strength strength-0">
6
      <p class="a2-password-strength--label">{% trans "Password strength :" %}
7
        <div class="a2-password-strength--name">{{ report.strength_label }}</div>
8
      </p>
9
      <div class="a2-password-strength--gauge">
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 class="a2-password-strength--bar"></div>
15
      </div>
16
    </div>
17
    <div class="a2-password-hint errornotice a2-password-hidden">
18
      <div class="a2-password-hint--text">
19
        <div class="a2-password-hint--to-weak">
20
          {% trans "Your password is too weak. To create a secure password, please " %}
21
        </div>
22
        <div class="a2-password-hint--ok">
23
          {% trans "Your password is strong enough. To create an even more secure password, you could " %}
24
        </div>
25
        <div class="a2-password-hint--content">{{ report.hint }}</div>
26
      </div>
27
    </div>
9 28
  </div>
10
</div>
29
{% else %}
30
  <div class="a2-password-policy-hint">
31
    {% trans "In order to create a secure password, please use at least :" %}
32
    <div class="a2-password-policy-container">
33
      {% for check in report.checks %}
34
        <span class="a2-password-policy-rule">{{ check.label }}</span>
35
      {% endfor %}
36
    </div>
37
  </div>
38
{% endif %}
tests/api/test_all.py
1757 1757

  
1758 1758

  
1759 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'})
1760
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
1761
    response = app.post_json('/api/validate-password/', params={'password': 'short'})
1762 1762
    assert response.json['result'] == 1
1763
    assert response.json['strength'] == 0
1763 1764
    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
1765
    assert response.json['hint'] == 'use at least 8 characters'
1767 1766

  
1767
    response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
1768
    assert response.json['result'] == 1
1769
    assert response.json['ok'] is False
1770
    assert response.json['hint'] == 'Add another word or two. Uncommon words are better.'
1768 1771
    response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
1769 1772
    assert response.json['result'] == 1
1770 1773
    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
1774
    assert response.json['hint'] == 'add more words or characters'
1774 1775

  
1775 1776

  
1776 1777
def test_api_users_get_or_create(settings, app, admin):
1777
-