0001-misc-add-password-strength-meter-in-NewPasswordInput.patch
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 |
- |