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