0001-add-new-API-to-validate-passwords-fixes-24833.patch
src/authentic2/api_urls.py | ||
---|---|---|
13 | 13 |
api_views.role_memberships, name='a2-api-role-member'), |
14 | 14 |
url(r'^check-password/$', api_views.check_password, |
15 | 15 |
name='a2-api-check-password'), |
16 |
url(r'^validate-password/$', api_views.validate_password, |
|
17 |
name='a2-api-validate-password'), |
|
16 | 18 |
] |
17 | 19 | |
18 | 20 |
urlpatterns += api_views.router.urls |
src/authentic2/api_views.py | ||
---|---|---|
25 | 25 | |
26 | 26 |
from django_filters.rest_framework import FilterSet |
27 | 27 | |
28 |
from .passwords import get_password_checker |
|
28 | 29 |
from .custom_user.models import User |
29 | 30 |
from . import utils, decorators, attribute_kinds, app_settings, hooks |
30 | 31 |
from .models import Attribute, PasswordReset, Service |
... | ... | |
717 | 718 | |
718 | 719 | |
719 | 720 |
check_password = CheckPasswordAPI.as_view() |
721 | ||
722 | ||
723 |
class ValidatePasswordSerializer(serializers.Serializer): |
|
724 |
password = serializers.CharField(required=True) |
|
725 | ||
726 | ||
727 |
class ValidatePasswordAPI(BaseRpcView): |
|
728 |
permission_classes = () |
|
729 |
serializer_class = ValidatePasswordSerializer |
|
730 | ||
731 |
def rpc(self, request, serializer): |
|
732 |
password_checker = get_password_checker() |
|
733 |
checks = [] |
|
734 |
result = {'result': 1, 'checks': checks} |
|
735 |
ok = True |
|
736 |
for check in password_checker(serializer.validated_data['password']): |
|
737 |
ok = ok and check.result |
|
738 |
checks.append({ |
|
739 |
'result': check.result, |
|
740 |
'label': check.label, |
|
741 |
}) |
|
742 |
result['ok'] = ok |
|
743 |
return result, status.HTTP_200_OK |
|
744 | ||
745 | ||
746 |
validate_password = ValidatePasswordAPI.as_view() |
src/authentic2/app_settings.py | ||
---|---|---|
143 | 143 |
A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'), |
144 | 144 |
A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'), |
145 | 145 |
A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'), |
146 |
A2_PASSWORD_POLICY_CLASS=Setting( |
|
147 |
default='authentic2.passwords.DefaultPasswordChecker', |
|
148 |
definition='path of a class to validate passwords'), |
|
146 | 149 |
A2_AUTH_PASSWORD_ENABLE=Setting(default=True, definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), |
147 | 150 |
A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(default=0, |
148 | 151 |
definition='Failure count before logging a warning to ' |
src/authentic2/passwords.py | ||
---|---|---|
1 | 1 |
import string |
2 | 2 |
import random |
3 |
import re |
|
4 |
import abc |
|
3 | 5 | |
6 |
from django.utils.translation import ugettext as _ |
|
7 |
from django.utils.module_loading import import_string |
|
4 | 8 |
from . import app_settings |
5 | 9 | |
10 | ||
6 | 11 |
def generate_password(): |
7 | 12 |
'''Generate a password that validates current password policy. |
8 | 13 | |
... | ... | |
22 | 27 |
new_password.append(random.choice(cls)) |
23 | 28 |
random.shuffle(new_password) |
24 | 29 |
return ''.join(new_password) |
30 | ||
31 | ||
32 |
class PasswordChecker(object): |
|
33 |
__metaclass__ = abc.ABCMeta |
|
34 | ||
35 |
class Check(object): |
|
36 |
def __init__(self, label, result): |
|
37 |
self.label = label |
|
38 |
self.result = result |
|
39 | ||
40 |
def __init__(self, *args, **kwargs): |
|
41 |
pass |
|
42 | ||
43 |
@abc.abstractmethod |
|
44 |
def __call__(self, password, **kwargs): |
|
45 |
'''Return an iterable of Check objects giving the list of checks and |
|
46 |
their result.''' |
|
47 |
return [] |
|
48 | ||
49 | ||
50 |
class DefaultPasswordChecker(PasswordChecker): |
|
51 |
@property |
|
52 |
def min_length(self): |
|
53 |
return app_settings.A2_PASSWORD_POLICY_MIN_LENGTH |
|
54 | ||
55 |
@property |
|
56 |
def at_least_one_lowercase(self): |
|
57 |
return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 0 |
|
58 | ||
59 |
@property |
|
60 |
def at_least_one_digit(self): |
|
61 |
return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 1 |
|
62 | ||
63 |
@property |
|
64 |
def at_least_one_uppercase(self): |
|
65 |
return app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 2 |
|
66 | ||
67 |
@property |
|
68 |
def regexp(self): |
|
69 |
return app_settings.A2_PASSWORD_POLICY_REGEX |
|
70 | ||
71 |
@property |
|
72 |
def regexp_label(self): |
|
73 |
return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG |
|
74 | ||
75 |
def __call__(self, password, **kwargs): |
|
76 |
if self.min_length: |
|
77 |
yield self.Check( |
|
78 |
result=len(password) >= self.min_length, |
|
79 |
label=_('%s characters') % self.min_length) |
|
80 | ||
81 |
if self.at_least_one_lowercase: |
|
82 |
yield self.Check( |
|
83 |
result=any(c.islower() for c in password), |
|
84 |
label=_('1 lowercase letter')) |
|
85 | ||
86 |
if self.at_least_one_digit: |
|
87 |
yield self.Check( |
|
88 |
result=any(c.isdigit() for c in password), |
|
89 |
label=_('1 digit')) |
|
90 | ||
91 |
if self.at_least_one_uppercase: |
|
92 |
yield self.Check( |
|
93 |
result=any(c.isupper() for c in password), |
|
94 |
label=_('1 uppercase letter')) |
|
95 | ||
96 |
if self.regexp and self.regexp_label: |
|
97 |
yield self.Check( |
|
98 |
result=bool(re.match(self.regexp, password)), |
|
99 |
label=self.regexp_label) |
|
100 | ||
101 | ||
102 |
def get_password_checker(*args, **kwargs): |
|
103 |
return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs) |
tests/test_api.py | ||
---|---|---|
859 | 859 |
app.authorization = ('Basic', (user.username, user.username)) |
860 | 860 |
resp = app.get('/api/users/') |
861 | 861 |
assert 'A2_OPENED_SESSION' not in app.cookies |
862 | ||
863 | ||
864 |
def test_validate_password_default(app): |
|
865 |
for password, ok, length, lower, digit, upper in ( |
|
866 |
('.', False, False, False, False, False), |
|
867 |
('x' * 8, False, True, True, False, False), |
|
868 |
('x' * 8 + '1', False, True, True, True, False), |
|
869 |
('x' * 8 + '1X', True, True, True, True, True)): |
|
870 |
response = app.post_json('/api/validate-password/', params={'password': password}) |
|
871 |
assert response.json['result'] == 1 |
|
872 |
assert response.json['ok'] is ok |
|
873 |
assert len(response.json['checks']) == 4 |
|
874 |
assert response.json['checks'][0]['label'] == '8 characters' |
|
875 |
assert response.json['checks'][0]['result'] is length |
|
876 |
assert response.json['checks'][1]['label'] == '1 lowercase letter' |
|
877 |
assert response.json['checks'][1]['result'] is lower |
|
878 |
assert response.json['checks'][2]['label'] == '1 digit' |
|
879 |
assert response.json['checks'][2]['result'] is digit |
|
880 |
assert response.json['checks'][3]['label'] == '1 uppercase letter' |
|
881 |
assert response.json['checks'][3]['result'] is upper |
|
882 | ||
883 | ||
884 |
def test_validate_password_regex(app, settings): |
|
885 |
settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$' |
|
886 |
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'must contain "ok"' |
|
887 | ||
888 |
response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + '1X'}) |
|
889 |
assert response.json['result'] == 1 |
|
890 |
assert response.json['ok'] is False |
|
891 |
assert len(response.json['checks']) == 5 |
|
892 |
assert response.json['checks'][0]['label'] == '8 characters' |
|
893 |
assert response.json['checks'][0]['result'] is True |
|
894 |
assert response.json['checks'][1]['label'] == '1 lowercase letter' |
|
895 |
assert response.json['checks'][1]['result'] is True |
|
896 |
assert response.json['checks'][2]['label'] == '1 digit' |
|
897 |
assert response.json['checks'][2]['result'] is True |
|
898 |
assert response.json['checks'][3]['label'] == '1 uppercase letter' |
|
899 |
assert response.json['checks'][3]['result'] is True |
|
900 |
assert response.json['checks'][4]['label'] == 'must contain "ok"' |
|
901 |
assert response.json['checks'][4]['result'] is False |
|
902 | ||
903 |
response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + 'ok1X'}) |
|
904 |
assert response.json['result'] == 1 |
|
905 |
assert response.json['ok'] is True |
|
906 |
assert len(response.json['checks']) == 5 |
|
907 |
assert response.json['checks'][0]['label'] == '8 characters' |
|
908 |
assert response.json['checks'][0]['result'] is True |
|
909 |
assert response.json['checks'][1]['label'] == '1 lowercase letter' |
|
910 |
assert response.json['checks'][1]['result'] is True |
|
911 |
assert response.json['checks'][2]['label'] == '1 digit' |
|
912 |
assert response.json['checks'][2]['result'] is True |
|
913 |
assert response.json['checks'][3]['label'] == '1 uppercase letter' |
|
914 |
assert response.json['checks'][3]['result'] is True |
|
915 |
assert response.json['checks'][4]['label'] == 'must contain "ok"' |
|
916 |
assert response.json['checks'][4]['result'] is True |
|
862 |
- |