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 |
... | ... | |
709 | 710 | |
710 | 711 | |
711 | 712 |
check_password = CheckPasswordAPI.as_view() |
713 | ||
714 | ||
715 |
class ValidatePasswordSerializer(serializers.Serializer): |
|
716 |
password = serializers.CharField(required=True) |
|
717 | ||
718 | ||
719 |
class ValidatePasswordAPI(BaseRpcView): |
|
720 |
permission_classes = () |
|
721 |
serializer_class = ValidatePasswordSerializer |
|
722 | ||
723 |
def rpc(self, request, serializer): |
|
724 |
password_checker = get_password_checker() |
|
725 |
checks = [] |
|
726 |
result = {'result': 1, 'checks': checks} |
|
727 |
ok = True |
|
728 |
for check in password_checker(serializer.validated_data['password']): |
|
729 |
ok = ok and check.result |
|
730 |
checks.append({ |
|
731 |
'result': check.result, |
|
732 |
'label': check.label, |
|
733 |
}) |
|
734 |
result['ok'] = ok |
|
735 |
return result, status.HTTP_200_OK |
|
736 | ||
737 | ||
738 |
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=_('at least %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=_('at least 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=_('at least 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=_('at least 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 | ||
---|---|---|
826 | 826 |
app.authorization = ('Basic', (user.username, user.username)) |
827 | 827 |
resp = app.get('/api/users/') |
828 | 828 |
assert 'A2_OPENED_SESSION' not in app.cookies |
829 | ||
830 | ||
831 |
def test_validate_password_default(app): |
|
832 |
for password, ok, length, lower, digit, upper in ( |
|
833 |
('.', False, False, False, False, False), |
|
834 |
('x' * 8, False, True, True, False, False), |
|
835 |
('x' * 8 + '1', False, True, True, True, False), |
|
836 |
('x' * 8 + '1X', True, True, True, True, True)): |
|
837 |
response = app.post_json('/api/validate-password/', params={'password': password}) |
|
838 |
assert response.json['result'] == 1 |
|
839 |
assert response.json['ok'] is ok |
|
840 |
assert len(response.json['checks']) == 4 |
|
841 |
assert response.json['checks'][0]['label'] == 'at least 8 characters' |
|
842 |
assert response.json['checks'][0]['result'] is length |
|
843 |
assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter' |
|
844 |
assert response.json['checks'][1]['result'] is lower |
|
845 |
assert response.json['checks'][2]['label'] == 'at least 1 digit' |
|
846 |
assert response.json['checks'][2]['result'] is digit |
|
847 |
assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter' |
|
848 |
assert response.json['checks'][3]['result'] is upper |
|
849 | ||
850 | ||
851 |
def test_validate_password_regex(app, settings): |
|
852 |
settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$' |
|
853 |
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'must contain "ok"' |
|
854 | ||
855 |
response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + '1X'}) |
|
856 |
assert response.json['result'] == 1 |
|
857 |
assert response.json['ok'] is False |
|
858 |
assert len(response.json['checks']) == 5 |
|
859 |
assert response.json['checks'][0]['label'] == 'at least 8 characters' |
|
860 |
assert response.json['checks'][0]['result'] is True |
|
861 |
assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter' |
|
862 |
assert response.json['checks'][1]['result'] is True |
|
863 |
assert response.json['checks'][2]['label'] == 'at least 1 digit' |
|
864 |
assert response.json['checks'][2]['result'] is True |
|
865 |
assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter' |
|
866 |
assert response.json['checks'][3]['result'] is True |
|
867 |
assert response.json['checks'][4]['label'] == 'must contain "ok"' |
|
868 |
assert response.json['checks'][4]['result'] is False |
|
869 | ||
870 |
response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + 'ok1X'}) |
|
871 |
assert response.json['result'] == 1 |
|
872 |
assert response.json['ok'] is True |
|
873 |
assert len(response.json['checks']) == 5 |
|
874 |
assert response.json['checks'][0]['label'] == 'at least 8 characters' |
|
875 |
assert response.json['checks'][0]['result'] is True |
|
876 |
assert response.json['checks'][1]['label'] == 'at least 1 lowercase letter' |
|
877 |
assert response.json['checks'][1]['result'] is True |
|
878 |
assert response.json['checks'][2]['label'] == 'at least 1 digit' |
|
879 |
assert response.json['checks'][2]['result'] is True |
|
880 |
assert response.json['checks'][3]['label'] == 'at least 1 uppercase letter' |
|
881 |
assert response.json['checks'][3]['result'] is True |
|
882 |
assert response.json['checks'][4]['label'] == 'must contain "ok"' |
|
883 |
assert response.json['checks'][4]['result'] is True |
|
829 |
- |